diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 2632f6c80c..dda0f41035 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -4041,6 +4041,10 @@ export async function push( originalWorkspaceSpecificPath, permissionedAsContext, isWsSpecific ? true : undefined, + { + noninteractive: (opts.yes ?? false) || !process.stdin.isTTY, + skipReencrypt: opts.skipReencryptOnKeyChange, + }, ); if (stateTarget) { @@ -4126,6 +4130,10 @@ export async function push( localFilePath, // Pass the actual local file path permissionedAsContext, isAddedWsSpecific ? true : undefined, + { + noninteractive: (opts.yes ?? false) || !process.stdin.isTTY, + skipReencrypt: opts.skipReencryptOnKeyChange, + }, ); if (stateTarget) { @@ -4682,6 +4690,10 @@ const command = new Command() .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") .option("--include-key", "Include workspace encryption key") + .option( + "--skip-reencrypt-on-key-change", + "When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.", + ) .option("--skip-branch-validation", "Skip git branch validation and prompts") .option("--json-output", "Output results in JSON format") .option( diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 5656cfe9d4..c4fda73b1b 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -88,6 +88,7 @@ export interface SyncOptions { includeGroups?: boolean; includeSettings?: boolean; includeKey?: boolean; + skipReencryptOnKeyChange?: boolean; skipBranchValidation?: boolean; message?: string; includes?: string[]; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 4b86cb3469..e4b618180f 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -445,11 +445,23 @@ export async function pushWorkspaceSettings( } } +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 + localKey: string, + opts?: PushWorkspaceKeyOptions ) { try { key = await wmill @@ -461,17 +473,46 @@ export async function pushWorkspaceKey( throw new Error(`Failed to get workspace encryption key: ${err}`); } if (localKey && key !== localKey) { - const confirm = 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, - }); + // 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: !confirm, + skip_reencrypt: !reencrypt, }, }); } else { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7dd25dc705..8b6f21053d 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6608,6 +6608,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings - \`--include-key\` - Include workspace encryption key + - \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - \`--skip-branch-validation\` - Skip git branch validation and prompts - \`--json-output\` - Output results in JSON format - \`-i --includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) diff --git a/cli/src/types.ts b/cli/src/types.ts index 75bad535c8..5de6cea48b 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -18,7 +18,11 @@ import { pushSchedule } from "./commands/schedule/schedule.ts"; import { pushWorkspaceUser } from "./commands/user/user.ts"; import { pushGroup } from "./commands/user/user.ts"; import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts"; -import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts"; +import { + pushWorkspaceSettings, + pushWorkspaceKey, + PushWorkspaceKeyOptions, +} from "./core/settings.ts"; import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts"; import { pushRawApp } from "./commands/app/raw_apps.ts"; import type { PermissionedAsContext } from "./core/permissioned_as.ts"; @@ -179,6 +183,7 @@ function redactString(s: string): string { * @param alreadySynced - Array to track already synced items * @param message - Optional commit/update message * @param originalLocalPath - The original local file path (used for branch-specific resource file resolution) + * @param keyPushOpts - Options for the encryption_key push: non-interactive flag and explicit re-encryption choice */ export async function pushObj( workspace: string, @@ -191,6 +196,7 @@ export async function pushObj( originalLocalPath?: string, permissionedAsContext?: PermissionedAsContext, wsSpecific?: boolean, + keyPushOpts?: PushWorkspaceKeyOptions, ) { const typeEnding = getTypeStrFromPath(p); @@ -256,7 +262,7 @@ export async function pushObj( } else if (typeEnding === "settings") { await pushWorkspaceSettings(workspace, p, befObj, newObj); } else if (typeEnding === "encryption_key") { - await pushWorkspaceKey(workspace, p, befObj, newObj); + await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts); } else { throw new Error( `The item ${p} has an unrecognized type ending ${typeEnding}` diff --git a/cli/test/push_workspace_key_unit.test.ts b/cli/test/push_workspace_key_unit.test.ts new file mode 100644 index 0000000000..6d8950cbec --- /dev/null +++ b/cli/test/push_workspace_key_unit.test.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for pushWorkspaceKey in settings.ts. + * + * Covers WIN-2005: changing the encryption key in encryption_key.yaml and + * pushing it must (by default) re-encrypt the remote secrets with the new key. + * + * Verifies that: + * - an unchanged key is a no-op (no setWorkspaceEncryptionKey call) + * - a changed key in non-interactive mode re-encrypts by default + * (skip_reencrypt = false), so secret plaintext values are preserved + * - the --skip-reencrypt-on-key-change flag keeps the remote ciphertexts + * untouched (skip_reencrypt = true) + * - WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true does the same via env var + */ + +import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; + +// Track calls to mocked wmill functions +let remoteKey = ""; +let setEncryptionKeyCalls: { + workspace: string; + requestBody: { new_key: string; skip_reencrypt?: boolean }; +}[] = []; + +// Mock the wmill module before importing settings.ts +mock.module("../gen/services.gen.ts", () => ({ + getWorkspaceEncryptionKey: async (_args: { workspace: string }) => ({ + key: remoteKey, + }), + setWorkspaceEncryptionKey: async (args: { + workspace: string; + requestBody: { new_key: string; skip_reencrypt?: boolean }; + }) => { + setEncryptionKeyCalls.push(args); + }, +})); + +import { pushWorkspaceKey } from "../src/core/settings.ts"; + +describe("pushWorkspaceKey", () => { + const ws = "test-workspace"; + + beforeEach(() => { + remoteKey = ""; + setEncryptionKeyCalls = []; + delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE; + }); + + afterEach(() => { + delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE; + }); + + test("no-op when local key matches the remote key", async () => { + remoteKey = "samekey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "samekey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(0); + }); + + test("changed key re-encrypts by default in non-interactive mode", async () => { + remoteKey = "oldkey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + // skip_reencrypt false => backend re-encrypts existing secrets with new key + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(false); + }); + + test("--skip-reencrypt-on-key-change skips re-encryption", async () => { + remoteKey = "oldkey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + skipReencrypt: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true); + }); + + test("WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true skips re-encryption non-interactively", async () => { + remoteKey = "oldkey"; + process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE = "true"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true); + }); +}); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index b02aabefe0..662bb58a53 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -583,6 +583,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--include-groups` - Include syncing groups - `--include-settings` - Include syncing workspace settings - `--include-key` - Include workspace encryption key + - `--skip-reencrypt-on-key-change` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - `--skip-branch-validation` - Skip git branch validation and prompts - `--json-output` - Output results in JSON format - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 19e69543e4..b0341495d3 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3133,6 +3133,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings - \`--include-key\` - Include workspace encryption key + - \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - \`--skip-branch-validation\` - Skip git branch validation and prompts - \`--json-output\` - Output results in JSON format - \`-i --includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 4b3e72a8b7..9eade530c7 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -588,6 +588,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--include-groups` - Include syncing groups - `--include-settings` - Include syncing workspace settings - `--include-key` - Include workspace encryption key + - `--skip-reencrypt-on-key-change` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - `--skip-branch-validation` - Skip git branch validation and prompts - `--json-output` - Output results in JSON format - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)