mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
01bad16c0c
* 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>
241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
});
|
|
});
|