diff --git a/cli/src/commands/protection-rules/converter.ts b/cli/src/commands/protection-rules/converter.ts new file mode 100644 index 0000000000..0d3ece5e36 --- /dev/null +++ b/cli/src/commands/protection-rules/converter.ts @@ -0,0 +1,132 @@ +import { ProtectionRuleEntry } from "./types.ts"; +import { ProtectionRuleset } from "../../../gen/types.gen.ts"; + +// Reconciliation plan produced by diffing the local protection rules +// against the backend list. `toDelete` holds names present on the backend but +// absent from wmill.yaml (full-reconcile semantics). +export interface ProtectionRulesPlan { + toCreate: ProtectionRuleEntry[]; + toUpdate: ProtectionRuleEntry[]; + toDelete: string[]; + unchanged: string[]; +} + +function sortedUnique(arr: readonly string[]): string[] { + return [...new Set(arr)].sort(); +} + +export class ProtectionRulesConverter { + // Canonicalize a single rule so comparisons are insensitive to array order + // and duplicates. + static normalizeEntry(entry: ProtectionRuleEntry): ProtectionRuleEntry { + return { + name: entry.name, + rules: sortedUnique(entry.rules ?? []) as ProtectionRuleEntry["rules"], + bypass_groups: sortedUnique(entry.bypass_groups ?? []), + bypass_users: sortedUnique(entry.bypass_users ?? []), + }; + } + + // Canonicalize and sort a list of rules by name. + static normalizeList( + entries: ProtectionRuleEntry[] | undefined, + ): ProtectionRuleEntry[] { + return (entries ?? []) + .map((e) => ProtectionRulesConverter.normalizeEntry(e)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + // Convert a backend ProtectionRuleset response into the wmill.yaml shape + // (drops workspace_id, which is implied by the synced workspace). + static fromBackend(rulesets: ProtectionRuleset[]): ProtectionRuleEntry[] { + return ProtectionRulesConverter.normalizeList( + rulesets.map((r) => ({ + name: r.name, + rules: [...(r.rules ?? [])], + bypass_groups: [...(r.bypass_groups ?? [])], + bypass_users: [...(r.bypass_users ?? [])], + })), + ); + } + + static entriesEqual( + a: ProtectionRuleEntry, + b: ProtectionRuleEntry, + ): boolean { + const na = ProtectionRulesConverter.normalizeEntry(a); + const nb = ProtectionRulesConverter.normalizeEntry(b); + return ( + na.name === nb.name && + na.rules.length === nb.rules.length && + na.rules.every((v, i) => v === nb.rules[i]) && + na.bypass_groups.length === nb.bypass_groups.length && + na.bypass_groups.every((v, i) => v === nb.bypass_groups[i]) && + na.bypass_users.length === nb.bypass_users.length && + na.bypass_users.every((v, i) => v === nb.bypass_users[i]) + ); + } + + static listsEqual( + a: ProtectionRuleEntry[] | undefined, + b: ProtectionRuleEntry[] | undefined, + ): boolean { + const na = ProtectionRulesConverter.normalizeList(a); + const nb = ProtectionRulesConverter.normalizeList(b); + if (na.length !== nb.length) return false; + return na.every((entry, i) => + ProtectionRulesConverter.entriesEqual(entry, nb[i]) + ); + } + + // Compute the create/update/delete plan to make `backend` match `local`. + static computePlan( + local: ProtectionRuleEntry[] | undefined, + backend: ProtectionRuleEntry[] | undefined, + ): ProtectionRulesPlan { + const localByName = new Map( + ProtectionRulesConverter.normalizeList(local).map((e) => [e.name, e]), + ); + const backendByName = new Map( + ProtectionRulesConverter.normalizeList(backend).map((e) => [e.name, e]), + ); + + const plan: ProtectionRulesPlan = { + toCreate: [], + toUpdate: [], + toDelete: [], + unchanged: [], + }; + + for (const [name, entry] of localByName) { + const existing = backendByName.get(name); + if (!existing) { + plan.toCreate.push(entry); + } else if (!ProtectionRulesConverter.entriesEqual(entry, existing)) { + plan.toUpdate.push(entry); + } else { + plan.unchanged.push(name); + } + } + + for (const name of backendByName.keys()) { + if (!localByName.has(name)) { + plan.toDelete.push(name); + } + } + + plan.toCreate.sort((a, b) => a.name.localeCompare(b.name)); + plan.toUpdate.sort((a, b) => a.name.localeCompare(b.name)); + plan.toDelete.sort(); + plan.unchanged.sort(); + + return plan; + } + + static planHasChanges(plan: ProtectionRulesPlan): boolean { + return ( + plan.toCreate.length > 0 || + plan.toUpdate.length > 0 || + plan.toDelete.length > 0 + ); + } +} diff --git a/cli/src/commands/protection-rules/file.ts b/cli/src/commands/protection-rules/file.ts new file mode 100644 index 0000000000..4b2742d4f6 --- /dev/null +++ b/cli/src/commands/protection-rules/file.ts @@ -0,0 +1,129 @@ +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { stringify as yamlStringify } from "yaml"; + +import { yamlParseFile } from "../../utils/yaml.ts"; +import { yamlOptions } from "../sync/sync.ts"; +import { + SyncOptions, + getWmillYamlPath, + getWorkspaceNames, + getEffectiveWorkspaceId, + WorkspaceEntryConfig, +} from "../../core/conf.ts"; +import { GlobalOptions } from "../../types.ts"; +import { tryResolveBranchWorkspace } from "../../core/context.ts"; +import { setClient } from "../../core/client.ts"; +import { ProtectionRulesFile } from "./types.ts"; + +export const PROTECTION_RULES_FILENAME = "protection-rules.yaml"; + +// protection-rules.yaml lives next to wmill.yaml. wmill.yaml is required: it is +// the single source of truth for which workspaces exist and how to reach them. +export function getProtectionRulesPath(): string | null { + const wmillPath = getWmillYamlPath(); + if (!wmillPath) return null; + return join(dirname(wmillPath), PROTECTION_RULES_FILENAME); +} + +export async function readProtectionRulesFile( + path: string, +): Promise { + if (!existsSync(path)) return {}; + const parsed = (await yamlParseFile(path)) as ProtectionRulesFile | null; + return parsed ?? {}; +} + +export async function writeProtectionRulesFile( + path: string, + data: ProtectionRulesFile, +): Promise { + // Deterministic key order so diffs/commits stay stable. + const sorted: ProtectionRulesFile = {}; + for (const k of Object.keys(data).sort()) sorted[k] = data[k]; + await writeFile(path, yamlStringify(sorted, yamlOptions), "utf-8"); +} + +// Maps a protection-rules.yaml workspace key to its backend workspace id via +// wmill.yaml's `workspaces` block. A key with no matching entry is rejected — +// without it we don't know which backend to talk to. +export class WorkspaceResolver { + private constructor( + private readonly workspaces: Record, + ) {} + + static fromConfig(config: SyncOptions): WorkspaceResolver { + const ws = (config.workspaces ?? {}) as Record< + string, + WorkspaceEntryConfig + >; + return new WorkspaceResolver(ws); + } + + /** Workspace keys declared in wmill.yaml (excludes reserved keys). */ + knownNames(): string[] { + return getWorkspaceNames(this.workspaces as any); + } + + has(name: string): boolean { + return this.knownNames().includes(name); + } + + /** Backend workspace id (path param) for a key, or throw if unknown. */ + backendId(name: string): string { + if (!this.has(name)) { + throw new Error( + `Workspace '${name}' is not defined in wmill.yaml 'workspaces'. ` + + `Add it there (its keys must match protection-rules.yaml).`, + ); + } + return getEffectiveWorkspaceId(name, this.workspaces[name]); + } +} + +// Point the API client at the backend for a single wmill.yaml workspace key, +// then return the backend workspace id to use as the path param. The backend +// id always comes from the wmill.yaml mapping (the feature's invariant); +// credentials are resolved with the same precedence as every other command: +// +// 1. explicit --base-url + --token -> used as-is (stateless CI; no profile +// or wmill.yaml baseUrl required) +// 2. otherwise, the stored profile matching wmill.yaml workspaces. +// (its baseUrl + token), with an explicit --token overriding the +// stored token +// +// Throws a clean error if the key is unknown or nothing resolves it — callers +// decide whether to skip (--all) or fail (named arg). +export async function configureClientForWorkspace( + opts: GlobalOptions, + ws: string, + resolver: WorkspaceResolver, +): Promise { + const wsId = resolver.backendId(ws); // throws if not in wmill.yaml + + // 1. Explicit credentials — honor them directly, like other commands do. + if (opts.baseUrl) { + if (!opts.token) { + throw new Error( + "When --base-url is set, --token is required for protection-rules.", + ); + } + setClient(opts.token, opts.baseUrl.replace(/\/+$/, "")); + return wsId; + } + + // 2. Stored-profile resolution. Fresh opts so resolveWorkspace's per-call + // cache can't bleed across keys. + const resolved = await tryResolveBranchWorkspace({ ...opts }, ws); + if (!resolved) { + throw new Error( + `Could not resolve credentials for workspace '${ws}'. Either pass ` + + `--base-url and --token, or ensure wmill.yaml workspaces.${ws} has a ` + + `baseUrl and you've run 'wmill workspace add' for it.`, + ); + } + // An explicit --token overrides the stored profile's token. + setClient(opts.token ?? resolved.token, resolved.remote.replace(/\/+$/, "")); + return wsId; +} diff --git a/cli/src/commands/protection-rules/index.ts b/cli/src/commands/protection-rules/index.ts new file mode 100644 index 0000000000..8ea8c47a1c --- /dev/null +++ b/cli/src/commands/protection-rules/index.ts @@ -0,0 +1,2 @@ +export { pullProtectionRules, pushProtectionRules } from "./protection-rules.ts"; +export { default } from "./protection-rules.ts"; diff --git a/cli/src/commands/protection-rules/protection-rules.ts b/cli/src/commands/protection-rules/protection-rules.ts new file mode 100644 index 0000000000..932a956333 --- /dev/null +++ b/cli/src/commands/protection-rules/protection-rules.ts @@ -0,0 +1,30 @@ +import { Command } from "@cliffy/command"; +import { pullProtectionRules } from "./pull.ts"; +import { pushProtectionRules } from "./push.ts"; + +const command = new Command() + .description( + "Sync workspace protection rules between protection-rules.yaml and Windmill. The file is keyed by workspace name; keys must match wmill.yaml 'workspaces'.", + ) + .command("pull") + .description( + "Pull protection rules from Windmill into protection-rules.yaml for a workspace", + ) + .arguments("[workspace:string]") + .option("--all", "Pull every workspace defined in wmill.yaml") + .option("--dry-run", "Show what would change without writing the file") + .option("--json-output", "Output in JSON format") + .action(pullProtectionRules as any) + .command("push") + .description( + "Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes)", + ) + .arguments("[workspace:string]") + .option("--all", "Push every workspace defined in protection-rules.yaml") + .option("--dry-run", "Show what would change without applying") + .option("--json-output", "Output in JSON format") + .option("--yes", "Skip the confirmation prompt (including deletions)") + .action(pushProtectionRules as any); + +export { pullProtectionRules, pushProtectionRules }; +export default command; diff --git a/cli/src/commands/protection-rules/pull.ts b/cli/src/commands/protection-rules/pull.ts new file mode 100644 index 0000000000..9086800422 --- /dev/null +++ b/cli/src/commands/protection-rules/pull.ts @@ -0,0 +1,143 @@ +import { colors } from "@cliffy/ansi/colors"; + +import * as log from "../../core/log.ts"; +import { GlobalOptions } from "../../types.ts"; +import * as wmill from "../../../gen/services.gen.ts"; +import { readConfigFile } from "../../core/conf.ts"; + +import { ProtectionRulesConverter } from "./converter.ts"; +import { ProtectionRulesFile } from "./types.ts"; +import { + PROTECTION_RULES_FILENAME, + getProtectionRulesPath, + readProtectionRulesFile, + writeProtectionRulesFile, + WorkspaceResolver, + configureClientForWorkspace, +} from "./file.ts"; +import { outputResult, fail, displayPlan, structuredPlan } from "./utils.ts"; + +type PullOpts = GlobalOptions & { + all?: boolean; + dryRun?: boolean; + jsonOutput?: boolean; +}; + +export async function pullProtectionRules( + opts: PullOpts, + workspaceArg?: string, +) { + // In JSON mode stdout must be exactly one JSON payload. Silence human logs + // (log.info/warn → stdout) here, before anything that logs (readConfigFile, + // workspace resolution). log.error still goes to stderr. + if (opts.jsonOutput) log.setSilent(true); + + const prPath = getProtectionRulesPath(); + if (!prPath) { + fail(opts, { + error: + "No wmill.yaml found. Run 'wmill init' first — protection-rules.yaml lives next to it.", + }); + } + + const config = await readConfigFile(); + const resolver = WorkspaceResolver.fromConfig(config); + + let targets: string[]; + if (opts.all) { + targets = resolver.knownNames(); + if (targets.length === 0) { + fail(opts, { + error: "No workspaces defined in wmill.yaml 'workspaces' block.", + }); + } + } else if (workspaceArg) { + targets = [workspaceArg]; + } else { + fail(opts, { error: "Specify a workspace name or use --all." }); + } + + const file = await readProtectionRulesFile(prPath!); + const perWs: Record = {}; + let hadError = false; + let anyChange = false; + + for (const ws of targets) { + let wsId: string; + try { + wsId = await configureClientForWorkspace(opts, ws, resolver); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (opts.all) { + log.error(colors.red(msg)); + hadError = true; + continue; + } + fail(opts, { error: msg }); + } + + let backend; + try { + backend = ProtectionRulesConverter.fromBackend( + await wmill.listProtectionRules({ workspace: wsId }), + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (opts.all) { + log.error(colors.red(`[${ws}] failed to fetch: ${msg}`)); + hadError = true; + continue; + } + fail(opts, { error: `Failed to fetch protection rules: ${msg}` }); + } + + const current = ProtectionRulesConverter.normalizeList(file[ws]); + // plan describes how the local file would change to match the backend + const plan = ProtectionRulesConverter.computePlan(backend, current); + if (ProtectionRulesConverter.planHasChanges(plan)) anyChange = true; + perWs[ws] = structuredPlan(plan); + + if (!opts.dryRun) { + file[ws] = backend; + } else if (!opts.jsonOutput) { + displayPlan(ws, plan); + } + } + + if (opts.dryRun) { + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: !hadError, + dryRun: true, + partialFailure: hadError, + hasChanges: anyChange, + workspaces: perWs, + }), + ); + } else if (!hadError && !anyChange) { + log.info(colors.green("All targeted workspaces are in sync")); + } + if (hadError) process.exit(1); + return; + } + + await writeProtectionRulesFile(prPath!, file as ProtectionRulesFile); + const n = Object.keys(perWs).length; + if (hadError) { + // Some --all workspaces failed: status must not say success while we + // exit non-zero. + outputResult(opts, { + success: false, + error: `Pulled ${n} workspace(s) into ${PROTECTION_RULES_FILENAME}, but one or more workspaces failed (see errors above)`, + partialFailure: true, + workspaces: perWs, + }); + process.exit(1); + } + outputResult(opts, { + success: true, + message: `Pulled protection rules for ${n} workspace(s) into ${PROTECTION_RULES_FILENAME}`, + workspaces: perWs, + }); +} diff --git a/cli/src/commands/protection-rules/push.ts b/cli/src/commands/protection-rules/push.ts new file mode 100644 index 0000000000..2ee253eaab --- /dev/null +++ b/cli/src/commands/protection-rules/push.ts @@ -0,0 +1,262 @@ +import process from "node:process"; +import { existsSync } from "node:fs"; + +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; + +import * as log from "../../core/log.ts"; +import { GlobalOptions } from "../../types.ts"; +import * as wmill from "../../../gen/services.gen.ts"; +import { readConfigFile } from "../../core/conf.ts"; + +import { ProtectionRulesConverter, ProtectionRulesPlan } from "./converter.ts"; +import { + getProtectionRulesPath, + readProtectionRulesFile, + WorkspaceResolver, + configureClientForWorkspace, +} from "./file.ts"; +import { outputResult, fail, displayPlan, structuredPlan } from "./utils.ts"; + +type PushOpts = GlobalOptions & { + all?: boolean; + dryRun?: boolean; + jsonOutput?: boolean; + yes?: boolean; +}; + +interface WsPlan { + ws: string; + wsId: string; + plan: ProtectionRulesPlan; + wipesAll: boolean; +} + +export async function pushProtectionRules( + opts: PushOpts, + workspaceArg?: string, +) { + // In JSON mode stdout must be exactly one JSON payload. Silence human logs + // (log.info/warn → stdout, incl. workspace resolution + the empty-list + // delete warning) before anything logs. log.error still goes to stderr. + if (opts.jsonOutput) log.setSilent(true); + + const prPath = getProtectionRulesPath(); + if (!prPath) { + fail(opts, { + error: + "No wmill.yaml found. Run 'wmill init' first — protection-rules.yaml lives next to it.", + }); + } + if (!existsSync(prPath!)) { + fail(opts, { + error: + "No protection-rules.yaml found. Run 'wmill protection-rules pull' first.", + }); + } + + const config = await readConfigFile(); + const resolver = WorkspaceResolver.fromConfig(config); + const file = await readProtectionRulesFile(prPath!); + + let targets: string[]; + if (opts.all) { + targets = Object.keys(file).sort(); + if (targets.length === 0) { + fail(opts, { error: "protection-rules.yaml defines no workspaces." }); + } + } else if (workspaceArg) { + if (!(workspaceArg in file)) { + fail(opts, { + error: `Workspace '${workspaceArg}' is not defined in protection-rules.yaml.`, + }); + } + targets = [workspaceArg]; + } else { + fail(opts, { error: "Specify a workspace name or use --all." }); + } + + // Phase 1: resolve + diff every target before mutating anything. + const wsPlans: WsPlan[] = []; + let hadError = false; + for (const ws of targets) { + let wsId: string; + try { + wsId = await configureClientForWorkspace(opts, ws, resolver); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (opts.all) { + log.error(colors.red(msg)); + hadError = true; + continue; + } + fail(opts, { error: msg }); + } + + let backend; + try { + backend = ProtectionRulesConverter.fromBackend( + await wmill.listProtectionRules({ workspace: wsId }), + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (opts.all) { + log.error(colors.red(`[${ws}] failed to fetch: ${msg}`)); + hadError = true; + continue; + } + fail(opts, { error: `Failed to fetch protection rules: ${msg}` }); + } + + const local = ProtectionRulesConverter.normalizeList(file[ws]); + const plan = ProtectionRulesConverter.computePlan(local, backend); + wsPlans.push({ + ws, + wsId, + plan, + wipesAll: local.length === 0 && plan.toDelete.length > 0, + }); + } + + const changed = wsPlans.filter((w) => + ProtectionRulesConverter.planHasChanges(w.plan) + ); + + if (opts.jsonOutput && opts.dryRun) { + console.log( + JSON.stringify({ + success: !hadError, + dryRun: true, + partialFailure: hadError, + hasChanges: changed.length > 0, + workspaces: Object.fromEntries( + wsPlans.map((w) => [w.ws, structuredPlan(w.plan)]), + ), + }), + ); + if (hadError) process.exit(1); + return; + } + + if (!opts.jsonOutput) { + for (const w of wsPlans) displayPlan(w.ws, w.plan); + } + + if (changed.length === 0) { + if (hadError) { + // A workspace failed to resolve/fetch — don't claim success while + // exiting non-zero. + outputResult(opts, { + success: false, + error: + "One or more workspaces failed (see errors above); the rest are in sync", + partialFailure: true, + }); + process.exit(1); + } + if (!opts.dryRun) { + outputResult(opts, { + success: true, + message: "No changes to push - all targeted workspaces are in sync", + }); + } + return; + } + + if (opts.dryRun) { + if (hadError) process.exit(1); + return; + } + + // Pushing an empty list wipes a workspace's rules — be loud even with --yes. + for (const w of wsPlans) { + if (w.wipesAll) { + log.warn( + colors.red( + `WARNING: '${w.ws}' has an empty rule list — this DELETES ALL ${w.plan.toDelete.length} backend rule(s) for that workspace.`, + ), + ); + } + } + + const totalDeletes = changed.reduce((n, w) => n + w.plan.toDelete.length, 0); + if (!opts.yes && !!process.stdin.isTTY) { + const confirmed = await Confirm.prompt({ + message: totalDeletes > 0 + ? `Apply these changes? This DELETES ${totalDeletes} protection rule(s) across ${changed.length} workspace(s).` + : `Apply these changes to ${changed.length} workspace(s)?`, + default: totalDeletes === 0, + }); + if (!confirmed) { + log.info("Operation cancelled"); + return; + } + } + + // Phase 2: apply. Track progress so a mid-run failure reports how far it got. + const applied = { created: 0, updated: 0, deleted: 0 }; + try { + for (const w of changed) { + // Re-point the client at this workspace (phase 1 left it on the last one). + await configureClientForWorkspace(opts, w.ws, resolver); + for (const entry of w.plan.toCreate) { + const n = ProtectionRulesConverter.normalizeEntry(entry); + await wmill.createProtectionRule({ + workspace: w.wsId, + requestBody: { + name: n.name, + rules: n.rules, + bypass_groups: n.bypass_groups, + bypass_users: n.bypass_users, + }, + }); + applied.created++; + } + for (const entry of w.plan.toUpdate) { + const n = ProtectionRulesConverter.normalizeEntry(entry); + await wmill.updateProtectionRule({ + workspace: w.wsId, + ruleName: n.name, + requestBody: { + rules: n.rules, + bypass_groups: n.bypass_groups, + bypass_users: n.bypass_users, + }, + }); + applied.updated++; + } + for (const name of w.plan.toDelete) { + await wmill.deleteProtectionRule({ + workspace: w.wsId, + ruleName: name, + }); + applied.deleted++; + } + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + fail(opts, { + error: + `Push partially failed after ${applied.created} create, ${applied.updated} update, ` + + `${applied.deleted} delete: ${msg}. Backend is partially reconciled; re-run push to converge.`, + applied, + }); + } + + if (hadError) { + // Reconcile of resolvable workspaces succeeded, but some --all targets + // failed earlier. Status must reflect the non-zero exit. + outputResult(opts, { + success: false, + error: `Pushed (created ${applied.created}, updated ${applied.updated}, deleted ${applied.deleted}) across ${changed.length} workspace(s), but one or more workspaces failed (see errors above)`, + partialFailure: true, + ...applied, + }); + process.exit(1); + } + outputResult(opts, { + success: true, + message: `Pushed protection rules (created ${applied.created}, updated ${applied.updated}, deleted ${applied.deleted}) across ${changed.length} workspace(s)`, + ...applied, + }); +} diff --git a/cli/src/commands/protection-rules/types.ts b/cli/src/commands/protection-rules/types.ts new file mode 100644 index 0000000000..0256f24e8f --- /dev/null +++ b/cli/src/commands/protection-rules/types.ts @@ -0,0 +1,18 @@ +import { ProtectionRuleKind } from "../../../gen/types.gen.ts"; + +export type { ProtectionRuleKind }; + +// A single workspace protection ruleset as stored in protection-rules.yaml. +// Mirrors the backend ProtectionRuleset shape minus workspace_id (the workspace +// is the map key). +export interface ProtectionRuleEntry { + name: string; + rules: ProtectionRuleKind[]; + bypass_groups: string[]; + bypass_users: string[]; +} + +// protection-rules.yaml is a flat map: workspace name -> its protection rules. +// Workspace names MUST match keys in wmill.yaml's `workspaces` block, which is +// where the backend workspaceId/remote is resolved from. +export type ProtectionRulesFile = Record; diff --git a/cli/src/commands/protection-rules/utils.ts b/cli/src/commands/protection-rules/utils.ts new file mode 100644 index 0000000000..9809e5808c --- /dev/null +++ b/cli/src/commands/protection-rules/utils.ts @@ -0,0 +1,73 @@ +import process from "node:process"; + +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { ProtectionRuleEntry } from "./types.ts"; +import { ProtectionRulesConverter, ProtectionRulesPlan } from "./converter.ts"; + +export function outputResult( + opts: { jsonOutput?: boolean }, + result: { + success: boolean; + message?: string; + error?: string; + [key: string]: any; + }, +): void { + if (opts.jsonOutput) { + console.log(JSON.stringify(result)); + } else if (result.success && result.message) { + log.info(colors.green(result.message)); + } else if (!result.success && result.error) { + log.error(colors.red(result.error)); + } +} + +// Report a genuine failure and exit non-zero so CI / scripted callers detect +// it. outputResult alone only logs, which would let a failed (possibly +// partial) reconcile hide behind exit code 0. +export function fail( + opts: { jsonOutput?: boolean }, + result: { error: string; [key: string]: any }, +): never { + outputResult(opts, { ...result, success: false }); + process.exit(1); +} + +function describeEntry(entry: ProtectionRuleEntry): string { + const n = ProtectionRulesConverter.normalizeEntry(entry); + const parts = [`rules=[${n.rules.join(", ")}]`]; + if (n.bypass_groups.length > 0) { + parts.push(`bypass_groups=[${n.bypass_groups.join(", ")}]`); + } + if (n.bypass_users.length > 0) { + parts.push(`bypass_users=[${n.bypass_users.join(", ")}]`); + } + return parts.join(" "); +} + +// Render a reconciliation plan with a per-workspace heading. +export function displayPlan(ws: string, plan: ProtectionRulesPlan): void { + log.info(colors.bold(`workspace ${ws}:`)); + for (const e of plan.toCreate) { + log.info(colors.green(` + ${e.name} (${describeEntry(e)})`)); + } + for (const e of plan.toUpdate) { + log.info(colors.yellow(` ~ ${e.name} (${describeEntry(e)})`)); + } + for (const name of plan.toDelete) { + log.info(colors.red(` - ${name}`)); + } + if (!ProtectionRulesConverter.planHasChanges(plan)) { + log.info(colors.green(" in sync")); + } +} + +export function structuredPlan(plan: ProtectionRulesPlan) { + return { + create: plan.toCreate.map((e) => e.name), + update: plan.toUpdate.map((e) => e.name), + delete: plan.toDelete, + unchanged: plan.unchanged, + }; +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 2b0962e09d..d2227b5f94 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6944,6 +6944,20 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`-w, --watch\` - Watch for file changes and re-lint automatically +### protection-rules + +**Subcommands:** + +- \`protection-rules pull [workspace:string]\` - Pull protection rules from Windmill into protection-rules.yaml for a workspace + - \`--all\` - Pull every workspace defined in wmill.yaml + - \`--dry-run\` - Show what would change without writing the file + - \`--json-output\` - Output in JSON format +- \`protection-rules push [workspace:string]\` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes) + - \`--all\` - Push every workspace defined in protection-rules.yaml + - \`--dry-run\` - Show what would change without applying + - \`--json-output\` - Output in JSON format + - \`--yes\` - Skip the confirmation prompt (including deletions) + ### queues List all queues with their metrics diff --git a/cli/src/main.ts b/cli/src/main.ts index dd078c6c9c..90dbf1164b 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -21,6 +21,7 @@ import schedule from "./commands/schedule/schedule.ts"; import trigger from "./commands/trigger/trigger.ts"; import sync from "./commands/sync/sync.ts"; import gitsyncSettings from "./commands/gitsync-settings/gitsync-settings.ts"; +import protectionRules from "./commands/protection-rules/protection-rules.ts"; import instance from "./commands/instance/instance.ts"; import workerGroups from "./commands/worker-groups/worker-groups.ts"; import lint from "./commands/lint/lint.ts"; @@ -65,6 +66,7 @@ export { sync, lint, gitsyncSettings, + protectionRules, instance, dev, docs, @@ -185,6 +187,7 @@ const command = new Command() .command("sync", sync) .command("lint", lint) .command("gitsync-settings", gitsyncSettings) + .command("protection-rules", protectionRules) .command("instance", instance) .command("worker-groups", workerGroups) .command("workers", workers) diff --git a/cli/test/protection_rules_converter_unit.test.ts b/cli/test/protection_rules_converter_unit.test.ts new file mode 100644 index 0000000000..c024cdf502 --- /dev/null +++ b/cli/test/protection_rules_converter_unit.test.ts @@ -0,0 +1,240 @@ +/** + * Unit tests for the protection-rules feature: the reconciliation converter, + * the WorkspaceResolver (protection-rules.yaml key -> backend id via + * wmill.yaml), and protection-rules.yaml read/write round-tripping. + */ + +import { expect, test, describe } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { ProtectionRulesConverter } from "../src/commands/protection-rules/converter.ts"; +import { + WorkspaceResolver, + readProtectionRulesFile, + writeProtectionRulesFile, +} from "../src/commands/protection-rules/file.ts"; +import { ProtectionRuleEntry } from "../src/commands/protection-rules/types.ts"; +import { SyncOptions } from "../src/core/conf.ts"; + +const rule = ( + name: string, + rules: ProtectionRuleEntry["rules"], + groups: string[] = [], + users: string[] = [], +): ProtectionRuleEntry => ({ + name, + rules, + bypass_groups: groups, + bypass_users: users, +}); + +describe("normalizeEntry", () => { + test("sorts and dedupes rules, groups, users", () => { + const r = rule( + "prod", + ["RestrictDeployToDeployers", "DisableDirectDeployment", "DisableDirectDeployment"], + ["g/b", "g/a"], + ["u/y", "u/x", "u/x"], + ); + const n = ProtectionRulesConverter.normalizeEntry(r); + expect(n.rules).toEqual([ + "DisableDirectDeployment", + "RestrictDeployToDeployers", + ]); + expect(n.bypass_groups).toEqual(["g/a", "g/b"]); + expect(n.bypass_users).toEqual(["u/x", "u/y"]); + }); + + test("handles missing arrays", () => { + const n = ProtectionRulesConverter.normalizeEntry({ + name: "x", + } as unknown as ProtectionRuleEntry); + expect(n.rules).toEqual([]); + expect(n.bypass_groups).toEqual([]); + expect(n.bypass_users).toEqual([]); + }); +}); + +describe("entriesEqual", () => { + test("equal regardless of array order", () => { + const a = rule("p", ["DisableDirectDeployment", "DisableWorkspaceForking"], ["g/a", "g/b"]); + const b = rule("p", ["DisableWorkspaceForking", "DisableDirectDeployment"], ["g/b", "g/a"]); + expect(ProtectionRulesConverter.entriesEqual(a, b)).toBe(true); + }); + + test("different rules are not equal", () => { + const a = rule("p", ["DisableDirectDeployment"]); + const b = rule("p", ["DisableWorkspaceForking"]); + expect(ProtectionRulesConverter.entriesEqual(a, b)).toBe(false); + }); + + test("different bypass users are not equal", () => { + const a = rule("p", ["DisableDirectDeployment"], [], ["u/a"]); + const b = rule("p", ["DisableDirectDeployment"], [], ["u/b"]); + expect(ProtectionRulesConverter.entriesEqual(a, b)).toBe(false); + }); +}); + +describe("fromBackend", () => { + test("strips workspace_id and normalizes", () => { + const out = ProtectionRulesConverter.fromBackend([ + { + name: "p", + workspace_id: "ws1", + rules: ["DisableWorkspaceForking", "DisableDirectDeployment"], + bypass_groups: ["g/b", "g/a"], + bypass_users: [], + }, + ]); + expect(out).toEqual([ + { + name: "p", + rules: ["DisableDirectDeployment", "DisableWorkspaceForking"], + bypass_groups: ["g/a", "g/b"], + bypass_users: [], + }, + ]); + }); +}); + +describe("listsEqual", () => { + test("equal regardless of list order", () => { + const a = [rule("a", ["DisableDirectDeployment"]), rule("b", ["DisableWorkspaceForking"])]; + const b = [rule("b", ["DisableWorkspaceForking"]), rule("a", ["DisableDirectDeployment"])]; + expect(ProtectionRulesConverter.listsEqual(a, b)).toBe(true); + }); + + test("undefined equals empty", () => { + expect(ProtectionRulesConverter.listsEqual(undefined, [])).toBe(true); + }); + + test("different length not equal", () => { + expect( + ProtectionRulesConverter.listsEqual([rule("a", [])], []), + ).toBe(false); + }); +}); + +describe("computePlan (full reconcile)", () => { + test("creates rules present locally but not on backend", () => { + const plan = ProtectionRulesConverter.computePlan( + [rule("new", ["DisableDirectDeployment"])], + [], + ); + expect(plan.toCreate.map((e) => e.name)).toEqual(["new"]); + expect(plan.toUpdate).toEqual([]); + expect(plan.toDelete).toEqual([]); + }); + + test("deletes backend rules not present locally", () => { + const plan = ProtectionRulesConverter.computePlan( + [], + [rule("stale", ["DisableDirectDeployment"])], + ); + expect(plan.toDelete).toEqual(["stale"]); + expect(plan.toCreate).toEqual([]); + }); + + test("updates rules whose content changed", () => { + const plan = ProtectionRulesConverter.computePlan( + [rule("p", ["DisableDirectDeployment", "DisableWorkspaceForking"])], + [rule("p", ["DisableDirectDeployment"])], + ); + expect(plan.toUpdate.map((e) => e.name)).toEqual(["p"]); + expect(plan.toCreate).toEqual([]); + expect(plan.toDelete).toEqual([]); + }); + + test("unchanged rules are not in create/update/delete", () => { + const same = [rule("p", ["DisableDirectDeployment"], ["g/a"])]; + const plan = ProtectionRulesConverter.computePlan(same, [ + rule("p", ["DisableDirectDeployment"], ["g/a"]), + ]); + expect(ProtectionRulesConverter.planHasChanges(plan)).toBe(false); + expect(plan.unchanged).toEqual(["p"]); + }); + + test("mixed plan: create + update + delete + unchanged", () => { + const local = [ + rule("keep", ["DisableDirectDeployment"]), + rule("change", ["DisableWorkspaceForking"]), + rule("brand-new", ["RestrictDeployToDeployers"]), + ]; + const backend = [ + rule("keep", ["DisableDirectDeployment"]), + rule("change", ["DisableDirectDeployment"]), + rule("gone", ["DisableDirectDeployment"]), + ]; + const plan = ProtectionRulesConverter.computePlan(local, backend); + expect(plan.toCreate.map((e) => e.name)).toEqual(["brand-new"]); + expect(plan.toUpdate.map((e) => e.name)).toEqual(["change"]); + expect(plan.toDelete).toEqual(["gone"]); + expect(plan.unchanged).toEqual(["keep"]); + expect(ProtectionRulesConverter.planHasChanges(plan)).toBe(true); + }); + + test("reordered arrays do not produce spurious updates", () => { + const plan = ProtectionRulesConverter.computePlan( + [rule("p", ["DisableWorkspaceForking", "DisableDirectDeployment"], ["g/b", "g/a"])], + [rule("p", ["DisableDirectDeployment", "DisableWorkspaceForking"], ["g/a", "g/b"])], + ); + expect(ProtectionRulesConverter.planHasChanges(plan)).toBe(false); + }); +}); + +describe("WorkspaceResolver", () => { + const config: SyncOptions = { + workspaces: { + prod: { workspaceId: "acme-prod" }, + dev: {}, + commonSpecificItems: { settings: true }, + } as any, + }; + const r = WorkspaceResolver.fromConfig(config); + + test("knownNames excludes reserved keys", () => { + expect(r.knownNames().sort()).toEqual(["dev", "prod"]); + }); + + test("backendId uses workspaceId when set, else the key name", () => { + expect(r.backendId("prod")).toBe("acme-prod"); + expect(r.backendId("dev")).toBe("dev"); + }); + + test("backendId throws for a key absent from wmill.yaml", () => { + expect(() => r.backendId("ghost")).toThrow(/not defined in wmill\.yaml/); + }); + + test("has reflects membership", () => { + expect(r.has("prod")).toBe(true); + expect(r.has("ghost")).toBe(false); + }); + + test("empty config resolves to no workspaces", () => { + expect(WorkspaceResolver.fromConfig({}).knownNames()).toEqual([]); + }); +}); + +describe("protection-rules.yaml read/write", () => { + test("round-trips and sorts workspace keys deterministically", async () => { + const dir = mkdtempSync(join(tmpdir(), "prfile-")); + const path = join(dir, "protection-rules.yaml"); + try { + expect(await readProtectionRulesFile(path)).toEqual({}); + + await writeProtectionRulesFile(path, { + prod: [rule("p", ["DisableDirectDeployment"], ["g/a"])], + dev: [], + }); + const back = await readProtectionRulesFile(path); + expect(Object.keys(back)).toEqual(["dev", "prod"]); + expect(back.prod).toEqual([ + rule("p", ["DisableDirectDeployment"], ["g/a"]), + ]); + expect(back.dev).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index dc0eb66640..eefa587666 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -330,6 +330,20 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks - `-w, --watch` - Watch for file changes and re-lint automatically +### protection-rules + +**Subcommands:** + +- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace + - `--all` - Pull every workspace defined in wmill.yaml + - `--dry-run` - Show what would change without writing the file + - `--json-output` - Output in JSON format +- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes) + - `--all` - Push every workspace defined in protection-rules.yaml + - `--dry-run` - Show what would change without applying + - `--json-output` - Output in JSON format + - `--yes` - Skip the confirmation prompt (including deletions) + ### queues List all queues with their metrics diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index f1c00d5763..a47b56aeaa 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2861,6 +2861,20 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`-w, --watch\` - Watch for file changes and re-lint automatically +### protection-rules + +**Subcommands:** + +- \`protection-rules pull [workspace:string]\` - Pull protection rules from Windmill into protection-rules.yaml for a workspace + - \`--all\` - Pull every workspace defined in wmill.yaml + - \`--dry-run\` - Show what would change without writing the file + - \`--json-output\` - Output in JSON format +- \`protection-rules push [workspace:string]\` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes) + - \`--all\` - Push every workspace defined in protection-rules.yaml + - \`--dry-run\` - Show what would change without applying + - \`--json-output\` - Output in JSON format + - \`--yes\` - Skip the confirmation prompt (including deletions) + ### queues List all queues with their metrics diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 98ce52efde..a729025eeb 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -335,6 +335,20 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks - `-w, --watch` - Watch for file changes and re-lint automatically +### protection-rules + +**Subcommands:** + +- `protection-rules pull [workspace:string]` - Pull protection rules from Windmill into protection-rules.yaml for a workspace + - `--all` - Pull every workspace defined in wmill.yaml + - `--dry-run` - Show what would change without writing the file + - `--json-output` - Output in JSON format +- `protection-rules push [workspace:string]` - Push protection rules from protection-rules.yaml to Windmill for a workspace (full reconcile: creates, updates, and deletes) + - `--all` - Push every workspace defined in protection-rules.yaml + - `--dry-run` - Show what would change without applying + - `--json-output` - Output in JSON format + - `--yes` - Skip the confirmation prompt (including deletions) + ### queues List all queues with their metrics