feat: add wmill protection-rules pull/push CLI commands (#9240)

* feat: add wmill protection-rules pull/push CLI commands

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: use directional keys for protection-rules pull --json diff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — exit non-zero on failure, resolve override workspace key

- failure paths in pull/push now exit 1 so CI/scripts detect failed reconciles
- --override writes under the resolved workspace key (findWorkspaceByGitBranch),
  not the raw branch, so gitBranch-mapped entries aren't left inert
- pull --replace clears a shadowing protectionRules override so top-level takes
  effect (was an infinite pull --diff loop)
- push reports applied create/update/delete counts on partial failure and warns
  loudly when an empty list would wipe all backend rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — dry-run pull --diff no longer writes; --promotion coherent

- pull --diff returns before the no-wmill.yaml bootstrap, so a dry run never
  creates/mutates wmill.yaml
- pull --promotion now writes/clears the promotion target's promotionOverrides
  (the same block getEffectiveSettings reads), instead of the current branch's
  regular overrides — read and write are now coherent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: move protection rules to a per-workspace protection-rules.yaml

Replaces the wmill.yaml/SyncOptions integration (top-level + overrides +
promotionOverrides) with a dedicated protection-rules.yaml keyed by workspace
name. This removes the getEffectiveSettings layering that caused the override
shadowing / promotion-coherence / dry-run bugs entirely.

- protection-rules.yaml: { <workspace>: ProtectionRuleEntry[] }, keys must
  match wmill.yaml 'workspaces' (source of truth for backend id/baseUrl/token)
- commands reduced to: pull/push [workspace] | --all, with --dry-run
- per-workspace auth resolved via tryResolveBranchWorkspace + setClient
- push remains a full reconcile (create/update/delete) with delete confirm,
  empty-list wipe warning, partial-failure reporting, non-zero exit on failure
- conf.ts reverted to main; SyncOptions no longer carries protectionRules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — honor explicit --base-url/--token in protection-rules

configureClientForWorkspace bypassed the credential precedence other commands
use: explicit --base-url/--token now work for stateless CI (no stored profile
or wmill.yaml baseUrl needed), and an explicit --token overrides a stored
profile's token. The backend workspace id still derives from the wmill.yaml
mapping (feature invariant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address cubic review — consistent status on partial --all failure

cubic found that pull/push reported success:true while exiting non-zero on
partial --all failures, and that the push command description was missing from
the generated CLI docs.

- pull/push now report success:false + partialFailure:true (and exit 1) when
  any --all workspace fails; success:true only on full success
- .description() calls use single string literals (not + concatenation) so
  system_prompts/generate.py parses them; regenerated CLI docs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address review — --json-output must emit only JSON on stdout

Codex flagged that workspace resolution (tryResolveBranchWorkspace's log.info)
and push's empty-list delete warning print to stdout before the JSON payload,
breaking machine callers. Silence human logs via log.setSilent(true) as the
first action when --json-output is set (before readConfigFile / resolution);
log.error still goes to stderr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-19 17:40:34 +00:00
committed by GitHub
parent 355c837944
commit 01bad16c0c
14 changed files with 1088 additions and 0 deletions
@@ -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
);
}
}
+129
View File
@@ -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<ProtectionRulesFile> {
if (!existsSync(path)) return {};
const parsed = (await yamlParseFile(path)) as ProtectionRulesFile | null;
return parsed ?? {};
}
export async function writeProtectionRulesFile(
path: string,
data: ProtectionRulesFile,
): Promise<void> {
// 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<string, WorkspaceEntryConfig>,
) {}
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.<ws>
// (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<string> {
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;
}
@@ -0,0 +1,2 @@
export { pullProtectionRules, pushProtectionRules } from "./protection-rules.ts";
export { default } from "./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;
+143
View File
@@ -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<string, any> = {};
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,
});
}
+262
View File
@@ -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,
});
}
@@ -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<string, ProtectionRuleEntry[]>;
@@ -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,
};
}
+14
View File
@@ -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
+3
View File
@@ -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)
@@ -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 });
}
});
});
@@ -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
+14
View File
@@ -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
@@ -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