mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +00:00
81b23a2ba0
* feat: make the fork lineage the only deploy relationship `workspace_settings.deploy_to` (2023) and `workspace.parent_workspace_id` (2025) both expressed "which workspace does this one deploy into". Fork creation and dev-workspace attach seeded both, but nothing kept them in agreement, so every reader picked one and they disagreed. Drop `deploy_to`. A migration folds surviving pairs into the lineage: a sole claimant on a target with no dev workspace becomes that target's dev workspace and keeps its own job tags, while many-to-one pairs become plain forks. Pairs that the lineage cannot express -- dangling target, self-reference, chain, mutual -- are reported and left unlinked. Job tags were never lineage-aware: `per_workspace_tag` mapped any parented workspace to its parent while `$workspace` interpolated the raw id, so a fork running a script tagged `<tag>-$workspace` produced a tag no worker serves and the job queued forever. Both paths now resolve to the nearest ancestor whose id an admin would provision workers for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: preserve unconvertible deploy links and sweep tag caches on reparent Review findings on the deploy_to unification: - convert chains instead of discarding them, and keep whatever the lineage cannot express in workspace_deploy_to_unmigrated so the down migration can restore it - ignore soft-deleted workspaces when choosing between a dev workspace and a plain fork; an archived claimant was demoting live pairs - mirror attach_dev_workspace's git-sync strip, which the migration skipped - sweep the tag cache over whole subtrees on rename and delete: tag resolution now walks ancestors, so a nested fork kept a tag nothing serves - call a dev workspace a dev workspace in the settings copy - redirect a root away from ?tab=deploy_to instead of rendering an empty target Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: detect lineage cycles and record archived links in the deploy_to migration Second review round on the unification: - detect cycles over the lineage as it would exist after conversion, not over the deploy_to graph alone: a root whose target was one of its own forks closed a loop that no deploy_to edge revealed - record an archived source's link instead of filtering it out entirely, which dropped it with the column - treat a fork whose deploy_to merely repeats its parent as redundant rather than reporting every pre-existing fork as unmigrated - read the row count from the lineage update rather than the git-sync one - sweep the tag cache when archiving a dev workspace, the last site that mutates is_dev_workspace without one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: resolve $workspace on preprocessed flow tags regardless of $args Third review round on the unification: - a flow tag containing only `$workspace` skipped interpolation entirely on the preprocessed path, because the branch that ran it keys on `$args`. The raw tag was written back and named a queue no worker serves. Resolve `$workspace` before the branch and leave `$args` to it. - record the new table's foreign key in the schema summary - describe what the archive tag sweep actually does: the dev flag is cleared for any archived workspace, which is why it is unconditional Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the deploy_to leftovers table only when it holds something * fix: sweep tag caches on archive only where the dev flag actually changes * feat: broadcast lineage changes and walk ws_specific ancestors only - propagate tag-cache invalidation across processes over notify_events: the cache is per-process, so replicas kept resolving stale lineage for the TTL. The listener clears the whole cache rather than tracking ids, since a single mutation invalidates an unbounded set of descendants and lineage changes are rare admin actions. - narrow list_ws_specific_versions to ancestors: walking down as well made a root fan out over its entire live fork subtree, and each member costs an identity lookup plus an RLS switch and probe. Ancestors are bounded by the fork depth limit. - probe the leftovers table unqualified so rollback restores on a PG_SCHEMA install, where search_path is not public - drop the nativets client method for the removed edit_deploy_to endpoint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: let a prod see its dev workspace in ws_specific, and stop the walk oscillating Descending into plain forks made a root fan out over its whole live fork subtree, but a dev workspace is the paired editable environment rather than a throwaway copy, so a prod should still see it. There is at most one per parent and attach rejects nested dev chains, so that edge stays bounded. The edges run both ways, so the recursion never converged: it bounced parent<->dev until the depth cap on every call, 33 rows for a two-member set. A visited-path guard ends the walk when nothing new is reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep dev pairings unnested, gate the delete broadcast, cover the ws_specific walk Fifth review round: - a root that already owns a dev workspace no longer converts: linking it under its deploy target would leave that dev nested beneath a fork, the shape attach_dev_workspace refuses to create. The link is preserved instead. - broadcast a lineage change on delete only when descendants are orphaned. Deleting a leaf, which ephemeral fork churn does constantly, changes nobody else's resolution and was making every replica drop its whole tag cache. - call list_ws_specific_versions in a test. plpgsql defers everything past a raw parse to the first call, so replaying the migration only proved it parses. - use unwrap_or_default for the descendant sweeps, which run after the transaction has committed; a transient failure must not fail the request - trim the traversal comment to the four-line limit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: cache the renamed tally query and clear instance alerts on conversion The integration test's query was never cached: `cargo sqlx prepare` without --all-targets skips test targets entirely, and renaming its fixture workspace changed the query text. Regenerated with --all-targets --features all_sqlx_features,private, which is what lets the EE-gated otel test compile. Also from review: - clear error_handler_fallback_to_instance_alerts on converted workspaces. Dispatch ignores it once a parent exists, but the settings page keeps submitting the stored true, which the API rejects on a fork. - restore the schema summary row to the file's name: columns format and put it back in alphabetical order Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: never cache an unresolvable tag workspace, and unadvertise the removed endpoint - lookup_tag_workspace cached a "no row" result as self-resolution. A rename resolves the new id before its row lands, so a fork could be pinned to its own wm-fork-* id -- which nothing serves -- for the whole TTL, and its schedules kept re-pushing onto that dead tag. Fall back for the call without caching, matching how the error path already behaved. - change_workspace_id swept its children but never itself. Sweep the new and old ids and broadcast unconditionally, since a rename always changes lineage. - openapi-deref.{json,yaml} are served to clients via include_str!, so they were advertising edit_deploy_to after it started 404ing. The audit-action enum keeps the entry: historical rows still carry it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: align the served YAML spec with the JSON one and correct two comments - the YAML deref lost the removed path but kept deploy_to on get_settings, so the two served specs disagreed. Both are now identical. - the rename-sweep comment blamed cached-unresolvable lookups, which the same commit stopped caching. The real reason is that workspace ids are reclaimable, so a new id can carry a previous occupant's resolution. - the instance-alert comment claimed the settings page submits the stored true and gets a 400. It hides the option on a fork and sends false; the hazard is the value outliving the pairing and re-enabling alerts after a detach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 82da6cb2bafeda18acd6b70c599013a12117ecb0 This commit updates the EE repository reference after PR #694 was merged in windmill-ee-private. Previous ee-repo-ref: f9ddf6a75aa13d1c13a3d7216a361a96f75ca435 New ee-repo-ref: 82da6cb2bafeda18acd6b70c599013a12117ecb0 Automated by sync-ee-ref workflow. * fix: grant the deploy_to preservation table to the windmill roles * test: drop the one-shot migration tests, keep the ws_specific execution guard The two conversion tests replayed the migration against the fully-migrated schema, which is not how it runs -- in production it runs mid-sequence against the schema as of that point. A later migration touching workspace or workspace_settings would break them without breaking anything real, and sqlx checksums already freeze a released migration. They earned their keep finding the archived-claimant and nested-dev cases during development; there is nothing left for them to guard. list_ws_specific_versions is different: it is live, no caller exercises it, and plpgsql only parses a function body until first call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: invalidate a reclaimed fork id cluster-wide without flushing every entry Gating the delete broadcast on orphaned descendants stopped leaf churn flushing every replica, but fork ids are reclaimable: the deleting process invalidated locally while every other replica kept the old parent for the TTL, so a job pushed in a recreated fork routed to the previous parent's tag. The broadcast payload now carries meaning. A workspace id drops that one entry, used for leaf deletion where exactly one id changed what it denotes. The `*` sentinel drops everything, used for attach, detach, archive, rename and deletions that orphan descendants -- reshaping a subtree no single id names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: name the right broadcast for each invalidation case * docs: attach does invalidate the tag cache; the resolver walks the whole chain --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
764 lines
25 KiB
TypeScript
764 lines
25 KiB
TypeScript
import process from "node:process";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { colors } from "@cliffy/ansi/colors";
|
|
import { Confirm } from "@cliffy/prompt/confirm";
|
|
import * as log from "./log.ts";
|
|
import { yamlParseFile } from "../utils/yaml.ts";
|
|
import { stringify as yamlStringify } from "yaml";
|
|
import * as wmill from "../../gen/services.gen.ts";
|
|
import { AIConfig, Config, GlobalSetting } from "../../gen/types.gen.ts";
|
|
import { compareInstanceObjects, InstanceSyncOptions } from "../commands/instance/instance.ts";
|
|
import { isSuperset } from "../types.ts";
|
|
import { deepEqual } from "../utils/utils.ts";
|
|
import { decrypt, encrypt } from "../utils/local_encryption.ts";
|
|
|
|
// New grouped config interfaces
|
|
export interface AutoInviteConfig {
|
|
enabled: boolean;
|
|
domain?: string;
|
|
operator: boolean;
|
|
mode: "invite" | "add";
|
|
instance_groups?: string[];
|
|
instance_groups_roles?: Record<string, string>;
|
|
}
|
|
|
|
export interface ErrorHandlerConfig {
|
|
path?: string;
|
|
extra_args?: any;
|
|
muted_on_cancel?: boolean;
|
|
muted_on_user_path?: boolean;
|
|
}
|
|
|
|
export interface SuccessHandlerConfig {
|
|
path?: string;
|
|
extra_args?: any;
|
|
}
|
|
|
|
export interface SimplifiedSettings {
|
|
// Grouped format (current). Explicit `null` on error_handler / success_handler
|
|
// signals "clear the remote value"; `undefined` means "not managed by git".
|
|
auto_invite?: AutoInviteConfig;
|
|
error_handler?: ErrorHandlerConfig | null;
|
|
success_handler?: SuccessHandlerConfig | null;
|
|
|
|
// Other fields
|
|
webhook?: string;
|
|
ai_config?: AIConfig;
|
|
large_file_storage?: any;
|
|
git_sync?: any;
|
|
default_app?: string;
|
|
default_scripts?: any;
|
|
name: string;
|
|
mute_critical_alerts?: boolean;
|
|
color?: string;
|
|
operator_settings?: any;
|
|
datatable?: any;
|
|
slack_team_id?: string;
|
|
slack_name?: string;
|
|
slack_command_script?: string;
|
|
slack_oauth_client_id?: string;
|
|
slack_oauth_client_secret?: string;
|
|
}
|
|
|
|
// Legacy settings interface for reading old settings.yaml files
|
|
interface LegacySimplifiedSettings {
|
|
auto_invite_enabled?: boolean;
|
|
auto_invite_as?: string;
|
|
auto_invite_mode?: string;
|
|
error_handler?: string;
|
|
error_handler_extra_args?: any;
|
|
error_handler_muted_on_cancel?: boolean;
|
|
success_handler?: string;
|
|
success_handler_extra_args?: any;
|
|
// Other fields same as SimplifiedSettings
|
|
webhook?: string;
|
|
ai_config?: AIConfig;
|
|
large_file_storage?: any;
|
|
git_sync?: any;
|
|
default_app?: string;
|
|
default_scripts?: any;
|
|
name?: string;
|
|
mute_critical_alerts?: boolean;
|
|
color?: string;
|
|
operator_settings?: any;
|
|
slack_team_id?: string;
|
|
slack_name?: string;
|
|
slack_command_script?: string;
|
|
slack_oauth_client_id?: string;
|
|
slack_oauth_client_secret?: string;
|
|
}
|
|
|
|
// Helper to convert legacy flat settings to new grouped format
|
|
export function migrateToGroupedFormat(settings: any): SimplifiedSettings {
|
|
const result: SimplifiedSettings = { name: settings.name ?? "" };
|
|
|
|
// Copy non-legacy fields
|
|
if (settings.webhook !== undefined) result.webhook = settings.webhook;
|
|
if (settings.ai_config !== undefined) result.ai_config = settings.ai_config;
|
|
if (settings.large_file_storage !== undefined) result.large_file_storage = settings.large_file_storage;
|
|
if (settings.git_sync !== undefined) result.git_sync = settings.git_sync;
|
|
if (settings.default_app !== undefined) result.default_app = settings.default_app;
|
|
if (settings.default_scripts !== undefined) result.default_scripts = settings.default_scripts;
|
|
if (settings.mute_critical_alerts !== undefined) result.mute_critical_alerts = settings.mute_critical_alerts;
|
|
if (settings.color !== undefined) result.color = settings.color;
|
|
if (settings.operator_settings !== undefined) result.operator_settings = settings.operator_settings;
|
|
if (settings.datatable !== undefined) result.datatable = settings.datatable;
|
|
if (settings.slack_team_id !== undefined) result.slack_team_id = settings.slack_team_id;
|
|
if (settings.slack_name !== undefined) result.slack_name = settings.slack_name;
|
|
if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script;
|
|
if (settings.slack_oauth_client_id !== undefined) result.slack_oauth_client_id = settings.slack_oauth_client_id;
|
|
if (settings.slack_oauth_client_secret !== undefined) result.slack_oauth_client_secret = settings.slack_oauth_client_secret;
|
|
|
|
// Handle auto_invite: check if already grouped or needs migration
|
|
if (settings.auto_invite && typeof settings.auto_invite === "object") {
|
|
result.auto_invite = settings.auto_invite;
|
|
} else if (settings.auto_invite_enabled !== undefined) {
|
|
// Legacy format
|
|
result.auto_invite = {
|
|
enabled: settings.auto_invite_enabled,
|
|
operator: settings.auto_invite_as === "operator",
|
|
mode: (settings.auto_invite_mode as "invite" | "add") ?? "invite",
|
|
};
|
|
}
|
|
|
|
// Handle error_handler: check if already grouped or needs migration.
|
|
// Preserve explicit null as a signal to clear the remote handler (distinct
|
|
// from absent = "not managed by git, leave remote alone").
|
|
if (settings.error_handler === null) {
|
|
result.error_handler = null;
|
|
} else if (settings.error_handler && typeof settings.error_handler === "object") {
|
|
result.error_handler = settings.error_handler;
|
|
} else if (typeof settings.error_handler === "string") {
|
|
// Legacy format (error_handler was a string path)
|
|
result.error_handler = {
|
|
path: settings.error_handler,
|
|
extra_args: settings.error_handler_extra_args,
|
|
muted_on_cancel: settings.error_handler_muted_on_cancel ?? false,
|
|
};
|
|
}
|
|
|
|
// Handle success_handler: same semantics.
|
|
if (settings.success_handler === null) {
|
|
result.success_handler = null;
|
|
} else if (settings.success_handler && typeof settings.success_handler === "object") {
|
|
result.success_handler = settings.success_handler;
|
|
} else if (typeof settings.success_handler === "string") {
|
|
// Legacy format (success_handler was a string path)
|
|
result.success_handler = {
|
|
path: settings.success_handler,
|
|
extra_args: settings.success_handler_extra_args,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const INSTANCE_SETTINGS_PATH = "instance_settings.yaml";
|
|
let instanceSettingsPath = INSTANCE_SETTINGS_PATH;
|
|
async function checkInstanceSettingsPath(opts: InstanceSyncOptions) {
|
|
if (opts.prefix && opts.folderPerInstance && opts.prefixSettings) {
|
|
instanceSettingsPath = `${opts.prefix}/${INSTANCE_SETTINGS_PATH}`;
|
|
}
|
|
}
|
|
|
|
const INSTANCE_CONFIGS_PATH = "instance_configs.yaml";
|
|
let instanceConfigsPath = INSTANCE_CONFIGS_PATH;
|
|
async function checkInstanceConfigPath(opts: InstanceSyncOptions) {
|
|
if (opts.prefix && opts.folderPerInstance && opts.prefixSettings) {
|
|
instanceConfigsPath = `${opts.prefix}/${INSTANCE_CONFIGS_PATH}`;
|
|
}
|
|
}
|
|
|
|
export async function pushWorkspaceSettings(
|
|
workspace: string,
|
|
_path: string,
|
|
settings: SimplifiedSettings | undefined,
|
|
localSettings: SimplifiedSettings | any
|
|
) {
|
|
// Migrate local settings from legacy format if needed
|
|
localSettings = migrateToGroupedFormat(localSettings);
|
|
|
|
try {
|
|
const remoteSettings = await wmill.getSettings({
|
|
workspace,
|
|
});
|
|
|
|
const workspaceName = await wmill.getWorkspaceName({
|
|
workspace,
|
|
});
|
|
|
|
// Build settings from remote (now using grouped format)
|
|
settings = {
|
|
auto_invite: remoteSettings.auto_invite as AutoInviteConfig | undefined,
|
|
error_handler: remoteSettings.error_handler as ErrorHandlerConfig | undefined,
|
|
success_handler: remoteSettings.success_handler as SuccessHandlerConfig | undefined,
|
|
webhook: remoteSettings.webhook,
|
|
ai_config: remoteSettings.ai_config,
|
|
large_file_storage: remoteSettings.large_file_storage,
|
|
git_sync: remoteSettings.git_sync,
|
|
default_app: remoteSettings.default_app,
|
|
default_scripts: remoteSettings.default_scripts,
|
|
name: workspaceName,
|
|
mute_critical_alerts: remoteSettings.mute_critical_alerts,
|
|
color: remoteSettings.color,
|
|
operator_settings: remoteSettings.operator_settings,
|
|
datatable: remoteSettings.datatable,
|
|
slack_team_id: remoteSettings.slack_team_id,
|
|
slack_name: remoteSettings.slack_name,
|
|
slack_command_script: remoteSettings.slack_command_script,
|
|
slack_oauth_client_id: remoteSettings.slack_oauth_client_id,
|
|
slack_oauth_client_secret: remoteSettings.slack_oauth_client_secret,
|
|
};
|
|
} catch (err) {
|
|
throw new Error(`Failed to get workspace settings: ${err}`);
|
|
}
|
|
|
|
// Exclude fields that are never applied here: slack_team_id/slack_name are OAuth-only,
|
|
// and name is not applied on pull (see below), so a name-only diff stays a no-op.
|
|
const { slack_team_id: _lst, slack_name: _lsn, name: _ln, ...comparableLocal } = localSettings;
|
|
const { slack_team_id: _rst, slack_name: _rsn, name: _rn, ...comparableRemote } = settings;
|
|
if (isSuperset(comparableLocal, comparableRemote)) {
|
|
log.debug(`Workspace settings are up to date`);
|
|
return;
|
|
}
|
|
log.debug(`Workspace settings are not up-to-date, updating...`);
|
|
|
|
if (localSettings.webhook !== settings.webhook) {
|
|
log.debug(`Updating webhook...`);
|
|
await wmill.editWebhook({
|
|
workspace,
|
|
requestBody: {
|
|
webhook: localSettings.webhook,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handle auto_invite using grouped format
|
|
if (!deepEqual(localSettings.auto_invite, settings.auto_invite)) {
|
|
log.debug(`Updating auto invite...`);
|
|
|
|
const localAutoInvite = localSettings.auto_invite;
|
|
if (localAutoInvite?.mode && !["add", "invite"].includes(localAutoInvite.mode)) {
|
|
throw new Error(
|
|
`Invalid value for auto_invite.mode. Valid values are "invite" and "add"`
|
|
);
|
|
}
|
|
try {
|
|
await wmill.editAutoInvite({
|
|
workspace,
|
|
requestBody: localAutoInvite?.enabled
|
|
? {
|
|
operator: localAutoInvite.operator ?? false,
|
|
invite_all: true,
|
|
auto_add: localAutoInvite.mode === "add",
|
|
}
|
|
: {},
|
|
});
|
|
} catch (_) {
|
|
// on cloud
|
|
log.debug(
|
|
`Auto invite is not possible on cloud, only auto-inviting same domain...`
|
|
);
|
|
await wmill.editAutoInvite({
|
|
workspace,
|
|
requestBody: localAutoInvite?.enabled
|
|
? {
|
|
operator: localAutoInvite.operator ?? false,
|
|
invite_all: false,
|
|
auto_add: localAutoInvite.mode === "add",
|
|
}
|
|
: {},
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!deepEqual(localSettings.ai_config, settings.ai_config)) {
|
|
log.debug(`Updating copilot settings...`);
|
|
await wmill.editCopilotConfig({
|
|
workspace,
|
|
requestBody: localSettings.ai_config ?? {},
|
|
});
|
|
}
|
|
|
|
// Handle error_handler using grouped format. YAML is canonical:
|
|
// absent / null → clear remote; present object → upsert.
|
|
// (Same "omit = clear" rule as every other workspace setting. Pull always
|
|
// emits the field as null when remote is NULL, so round-trip is bijective.)
|
|
if (!deepEqual(localSettings.error_handler, settings.error_handler)) {
|
|
log.debug(`Updating error handler...`);
|
|
const localErrorHandler = localSettings.error_handler;
|
|
await wmill.editErrorHandler({
|
|
workspace,
|
|
requestBody: {
|
|
path: localErrorHandler?.path,
|
|
extra_args: localErrorHandler?.extra_args,
|
|
muted_on_cancel: localErrorHandler?.muted_on_cancel ?? false,
|
|
muted_on_user_path: localErrorHandler?.muted_on_user_path ?? false,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handle success_handler using grouped format. Same semantics as error_handler.
|
|
if (!deepEqual(localSettings.success_handler, settings.success_handler)) {
|
|
log.debug(`Updating success handler...`);
|
|
const localSuccessHandler = localSettings.success_handler;
|
|
await wmill.editSuccessHandler({
|
|
workspace,
|
|
requestBody: {
|
|
path: localSuccessHandler?.path,
|
|
extra_args: localSuccessHandler?.extra_args,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
!deepEqual(localSettings.large_file_storage, settings.large_file_storage)
|
|
) {
|
|
log.debug(`Updating large file storage...`);
|
|
await wmill.editLargeFileStorageConfig({
|
|
workspace,
|
|
requestBody: {
|
|
large_file_storage: localSettings.large_file_storage,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!deepEqual(localSettings.git_sync, settings.git_sync)) {
|
|
log.debug(`Updating git sync...`);
|
|
await wmill.editWorkspaceGitSyncConfig({
|
|
workspace,
|
|
requestBody: {
|
|
git_sync_settings: localSettings.git_sync,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!deepEqual(localSettings.default_scripts, settings.default_scripts)) {
|
|
log.debug(`Updating default scripts...`);
|
|
await wmill.editDefaultScripts({
|
|
workspace,
|
|
requestBody: localSettings.default_scripts,
|
|
});
|
|
}
|
|
|
|
if (localSettings.default_app != settings.default_app) {
|
|
log.debug(`Updating default app...`);
|
|
await wmill.editWorkspaceDefaultApp({
|
|
workspace,
|
|
requestBody: {
|
|
default_app_path: localSettings.default_app,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Workspace display name is intentionally not applied on pull: settings.yaml is shared
|
|
// across a repo's branches, so applying it would let one workspace's name overwrite
|
|
// another's when both sync the same repo. It stays in the file (written on push), but a
|
|
// live workspace is only renamed by its owner.
|
|
|
|
if (localSettings.mute_critical_alerts != settings.mute_critical_alerts) {
|
|
log.debug(`Updating mute critical alerts...`);
|
|
await wmill.workspaceMuteCriticalAlertsUi({
|
|
workspace,
|
|
requestBody: {
|
|
mute_critical_alerts: localSettings.mute_critical_alerts,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (localSettings.color != settings.color) {
|
|
log.debug(`Updating workspace color...`);
|
|
await wmill.changeWorkspaceColor({
|
|
workspace,
|
|
requestBody: {
|
|
color: localSettings.color,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!deepEqual(localSettings.operator_settings, settings.operator_settings)) {
|
|
log.debug(`Updating operator settings...`);
|
|
await wmill.updateOperatorSettings({
|
|
workspace,
|
|
requestBody: localSettings.operator_settings,
|
|
});
|
|
}
|
|
|
|
if (!deepEqual(localSettings.datatable, settings.datatable)) {
|
|
log.debug(`Updating datatable config...`);
|
|
await wmill.editDataTableConfig({
|
|
workspace,
|
|
requestBody: { settings: localSettings.datatable ?? { datatables: {} } },
|
|
});
|
|
}
|
|
|
|
if (localSettings.slack_command_script != settings.slack_command_script) {
|
|
log.debug(`Updating slack command script...`);
|
|
await wmill.editSlackCommand({
|
|
workspace,
|
|
requestBody: {
|
|
slack_command_script: localSettings.slack_command_script,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Workspace-level Slack OAuth override. YAML is canonical (same rule as
|
|
// every other setting): both present → upsert; anything else → delete.
|
|
// Pull always emits both fields as null when remote is NULL.
|
|
if (
|
|
localSettings.slack_oauth_client_id != settings.slack_oauth_client_id ||
|
|
localSettings.slack_oauth_client_secret != settings.slack_oauth_client_secret
|
|
) {
|
|
log.debug(`Updating slack oauth config...`);
|
|
if (
|
|
localSettings.slack_oauth_client_id &&
|
|
localSettings.slack_oauth_client_secret
|
|
) {
|
|
await wmill.setWorkspaceSlackOauthConfig({
|
|
workspace,
|
|
requestBody: {
|
|
slack_oauth_client_id: localSettings.slack_oauth_client_id,
|
|
slack_oauth_client_secret: localSettings.slack_oauth_client_secret,
|
|
},
|
|
});
|
|
} else {
|
|
await wmill.deleteWorkspaceSlackOauthConfig({ workspace });
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface PushWorkspaceKeyOptions {
|
|
// True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a
|
|
// TTY). In that case the re-encryption decision is taken from `skipReencrypt`
|
|
// / the WMILL_NO_REENCRYPT_ON_KEY_CHANGE env var instead of an interactive
|
|
// confirmation.
|
|
noninteractive?: boolean;
|
|
// Explicit re-encryption decision from `--skip-reencrypt-on-key-change`.
|
|
// When set it takes precedence over the prompt and the env var.
|
|
skipReencrypt?: boolean;
|
|
}
|
|
|
|
export async function pushWorkspaceKey(
|
|
workspace: string,
|
|
_path: string,
|
|
key: string | undefined,
|
|
localKey: string,
|
|
opts?: PushWorkspaceKeyOptions
|
|
) {
|
|
try {
|
|
key = await wmill
|
|
.getWorkspaceEncryptionKey({
|
|
workspace,
|
|
})
|
|
.then((r) => r.key);
|
|
} catch (err) {
|
|
throw new Error(`Failed to get workspace encryption key: ${err}`);
|
|
}
|
|
if (localKey && key !== localKey) {
|
|
// Changing the key on the remote means the existing ciphertexts (encrypted
|
|
// with the old key) become unreadable unless they are re-encrypted. By
|
|
// default we ask the backend to re-encrypt every secret variable with the
|
|
// new key, which preserves their plaintext values. The only reason to skip
|
|
// re-encryption is when the stored ciphertexts are *already* encrypted with
|
|
// the new key (e.g. a workspace/instance migration).
|
|
let reencrypt: boolean;
|
|
// Explicit choice via `--skip-reencrypt-on-key-change` or the env var wins
|
|
// over everything, regardless of interactivity.
|
|
const explicitSkip =
|
|
opts?.skipReencrypt ||
|
|
(process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE ?? "").toLowerCase() ===
|
|
"true";
|
|
if (explicitSkip) {
|
|
reencrypt = false;
|
|
log.info(
|
|
"Workspace encryption key changed; leaving remote ciphertexts untouched (skip re-encryption requested)."
|
|
);
|
|
} else if (opts?.noninteractive) {
|
|
// No TTY (or --yes) and no explicit skip: we can't prompt, so default to
|
|
// re-encrypting (matches the interactive default) to preserve secret
|
|
// values. Pass --skip-reencrypt-on-key-change (or set
|
|
// WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true) to opt out.
|
|
reencrypt = true;
|
|
log.info(
|
|
"Workspace encryption key changed; re-encrypting all remote secrets with the new key (non-interactive)."
|
|
);
|
|
} else {
|
|
reencrypt = await Confirm.prompt({
|
|
message:
|
|
"The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n",
|
|
default: true,
|
|
});
|
|
}
|
|
log.debug(`Updating workspace encryption key...`);
|
|
await wmill.setWorkspaceEncryptionKey({
|
|
workspace,
|
|
requestBody: {
|
|
new_key: localKey,
|
|
skip_reencrypt: !reencrypt,
|
|
},
|
|
});
|
|
} else {
|
|
log.debug(`Workspace encryption key is up to date`);
|
|
}
|
|
}
|
|
|
|
export async function readInstanceSettings(opts: InstanceSyncOptions) {
|
|
let localSettings: GlobalSetting[] = [];
|
|
|
|
await checkInstanceSettingsPath(opts);
|
|
|
|
try {
|
|
localSettings = (await yamlParseFile(
|
|
instanceSettingsPath
|
|
)) as GlobalSetting[];
|
|
} catch {
|
|
log.warn(`No ${instanceSettingsPath} found`);
|
|
}
|
|
return localSettings;
|
|
}
|
|
|
|
const SENSITIVE_FIELD: string[] = ["license_key", "jwt_secret"];
|
|
|
|
async function processInstanceSettings(
|
|
settings: GlobalSetting[],
|
|
mode: "encode" | "decode"
|
|
): Promise<GlobalSetting[]> {
|
|
const encKey = process.env.WMILL_INSTANCE_LOCAL_ENCRYPTION_KEY;
|
|
if (encKey) {
|
|
const res: GlobalSetting[] = [];
|
|
|
|
for (const s of settings) {
|
|
if (SENSITIVE_FIELD.includes(s.name) && typeof s.value === "string") {
|
|
res.push(
|
|
(await processField(s, "value", encKey, mode)) as GlobalSetting
|
|
);
|
|
} else if (s.name == "oauths") {
|
|
if (typeof s.value === "object") {
|
|
const oauths = s.value as { [key: string]: any };
|
|
for (const [k, v] of Object.entries(oauths)) {
|
|
oauths[k] = await processField(v, "secret", encKey, mode);
|
|
}
|
|
res.push(s);
|
|
} else {
|
|
log.warn(`Unexpected oauths value type: ${typeof s.value}`);
|
|
res.push(s);
|
|
}
|
|
} else {
|
|
res.push(s);
|
|
}
|
|
}
|
|
return res;
|
|
} else {
|
|
log.warn(
|
|
"No encryption key found, skipping encryption. Recommend setting WMILL_INSTANCE_LOCAL_ENCRYPTION_KEY"
|
|
);
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
async function processField(
|
|
obj: { [key: string]: any },
|
|
field: string,
|
|
encKey: string,
|
|
mode: "encode" | "decode"
|
|
): Promise<{ [key: string]: any }> {
|
|
return {
|
|
...obj,
|
|
[field]:
|
|
mode === "encode"
|
|
? await encrypt(obj[field], encKey)
|
|
: ((await decrypt(obj[field], encKey)) as any),
|
|
};
|
|
}
|
|
|
|
export async function pullInstanceSettings(
|
|
opts: InstanceSyncOptions,
|
|
preview = false
|
|
) {
|
|
const remoteSettings = await wmill.listGlobalSettings();
|
|
|
|
await checkInstanceSettingsPath(opts);
|
|
|
|
if (preview) {
|
|
const localSettings: GlobalSetting[] = await readInstanceSettings(opts);
|
|
const processedSettings = await processInstanceSettings(
|
|
remoteSettings,
|
|
"encode"
|
|
);
|
|
return compareInstanceObjects(
|
|
processedSettings,
|
|
localSettings,
|
|
"name",
|
|
"setting"
|
|
);
|
|
} else {
|
|
log.info("Pulling settings from instance");
|
|
|
|
const processedSettings = await processInstanceSettings(
|
|
remoteSettings,
|
|
"encode"
|
|
);
|
|
await writeFile(
|
|
instanceSettingsPath,
|
|
yamlStringify(processedSettings),
|
|
"utf-8"
|
|
);
|
|
|
|
log.info(colors.green(`Settings written to ${instanceSettingsPath}`));
|
|
}
|
|
}
|
|
|
|
export async function pushInstanceSettings(
|
|
opts: InstanceSyncOptions,
|
|
preview: boolean = false
|
|
) {
|
|
const remoteSettings = await wmill.listGlobalSettings();
|
|
let localSettings: GlobalSetting[] = await readInstanceSettings(opts);
|
|
localSettings = await processInstanceSettings(localSettings, "decode");
|
|
|
|
if (opts.baseUrl) {
|
|
localSettings = localSettings.filter((s) => s.name !== "base_url");
|
|
localSettings.push({
|
|
name: "base_url",
|
|
//@ts-ignore
|
|
value: opts.baseUrl,
|
|
});
|
|
}
|
|
|
|
if (preview) {
|
|
return compareInstanceObjects(
|
|
localSettings,
|
|
remoteSettings,
|
|
"name",
|
|
"setting"
|
|
);
|
|
} else {
|
|
for (const setting of localSettings) {
|
|
const remoteMatch = remoteSettings.find((s) => s.name === setting.name);
|
|
if (remoteMatch && deepEqual(remoteMatch, setting)) {
|
|
continue;
|
|
}
|
|
try {
|
|
await wmill.setGlobal({
|
|
key: setting.name,
|
|
requestBody: {
|
|
value: setting.value,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
log.error(`Failed to set setting ${setting.name}: ${err}`);
|
|
}
|
|
}
|
|
|
|
for (const remoteSetting of remoteSettings) {
|
|
const localMatch = localSettings.find(
|
|
(s) => s.name === remoteSetting.name
|
|
);
|
|
if (!localMatch) {
|
|
try {
|
|
await wmill.setGlobal({
|
|
key: remoteSetting.name,
|
|
requestBody: {
|
|
value: null,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
log.error(`Failed to delete setting ${remoteSetting.name}: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
log.info(colors.green("Settings pushed to instance"));
|
|
}
|
|
}
|
|
|
|
export async function readLocalConfigs(opts: InstanceSyncOptions) {
|
|
let localConfigs: Config[] = [];
|
|
|
|
await checkInstanceConfigPath(opts);
|
|
|
|
try {
|
|
localConfigs = (await yamlParseFile(instanceConfigsPath)) as Config[];
|
|
} catch {
|
|
log.warn(`No ${instanceConfigsPath} found`);
|
|
}
|
|
return localConfigs;
|
|
}
|
|
|
|
export async function pullInstanceConfigs(
|
|
opts: InstanceSyncOptions,
|
|
preview = false
|
|
) {
|
|
const remoteConfigs = await wmill.listWorkerGroups();
|
|
|
|
if (preview) {
|
|
const localConfigs: Config[] = await readLocalConfigs(opts);
|
|
|
|
return compareInstanceObjects(
|
|
remoteConfigs,
|
|
localConfigs,
|
|
"name",
|
|
"config"
|
|
);
|
|
} else {
|
|
log.info("Pulling configs from instance");
|
|
|
|
await writeFile(
|
|
instanceConfigsPath,
|
|
yamlStringify(remoteConfigs as any),
|
|
"utf-8"
|
|
);
|
|
|
|
log.info(colors.green(`Configs written to ${instanceConfigsPath}`));
|
|
}
|
|
}
|
|
|
|
export async function pushInstanceConfigs(
|
|
opts: InstanceSyncOptions,
|
|
preview: boolean = false
|
|
) {
|
|
const remoteConfigs = await wmill.listWorkerGroups();
|
|
const localConfigs = await readLocalConfigs(opts);
|
|
|
|
if (preview) {
|
|
return compareInstanceObjects(
|
|
localConfigs,
|
|
remoteConfigs,
|
|
"name",
|
|
"config"
|
|
);
|
|
} else {
|
|
log.info("Pushing configs to instance");
|
|
for (const config of localConfigs) {
|
|
const remoteMatch = remoteConfigs.find((c) => c.name === config.name);
|
|
if (remoteMatch && deepEqual(remoteMatch, config)) {
|
|
continue;
|
|
}
|
|
try {
|
|
await wmill.updateConfig({
|
|
name: `worker__${config.name}`,
|
|
requestBody: config.config,
|
|
});
|
|
} catch (err) {
|
|
log.error(`Failed to set config ${config.name}: ${err}`);
|
|
}
|
|
}
|
|
|
|
for (const removeConfig of remoteConfigs) {
|
|
const localMatch = localConfigs.find((c) => c.name === removeConfig.name);
|
|
|
|
if (!localMatch) {
|
|
try {
|
|
await wmill.deleteConfig({
|
|
name: `worker__${removeConfig.name}`,
|
|
});
|
|
} catch (err) {
|
|
log.error(`Failed to delete config ${removeConfig.name}: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
log.info(colors.green("Configs pushed to instance"));
|
|
}
|
|
}
|