mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user