mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
fix(cli): make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change (#9402)
When encryption_key.yaml changes and is pushed via `wmill sync push`, pushWorkspaceKey prompted interactively to confirm re-encrypting the remote secrets with the new key. That prompt ignored `--yes` and had no TTY guard, so a CI/non-interactive push that included the key would block (or behave undefinedly) on the prompt. Thread a key-push options object (non-interactive flag + explicit re-encryption choice) through pushObj into pushWorkspaceKey: - Non-interactive (`--yes` or no TTY) and no explicit choice: skip the prompt and default to re-encrypting all remote secrets with the new key (matches the interactive default), preserving their plaintext values. - New `--skip-reencrypt-on-key-change` flag (and the WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true env var for CI) opt out of re-encryption — only safe when the remote ciphertexts are already encrypted with the new key (e.g. workspace/instance migration). - Interactive behavior (TTY, no `--yes`) is unchanged. Regenerates system_prompts for the new option and adds unit tests for the no-op, re-encrypt-by-default, flag-skip, and env-skip paths. Fixes WIN-2005 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d71d553ba4
commit
e356bb1f5d
@@ -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(
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface SyncOptions {
|
||||
includeGroups?: boolean;
|
||||
includeSettings?: boolean;
|
||||
includeKey?: boolean;
|
||||
skipReencryptOnKeyChange?: boolean;
|
||||
skipBranchValidation?: boolean;
|
||||
message?: string;
|
||||
includes?: string[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <patterns:file[]>\` - 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)
|
||||
|
||||
+8
-2
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user