Merge remote-tracking branch 'origin/master' into feat/sub2rank-pac-delivery
# Conflicts: # scripts/src/platform-infra-gitea-gitops-delivery.test.ts # scripts/src/platform-infra-pac-provenance.test.ts # scripts/src/platform-infra-pipelines-as-code-source-artifact.ts # scripts/src/platform-infra-pipelines-as-code.ts
This commit is contained in:
@@ -91,7 +91,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ServiceAccount",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
"ValidatingAdmissionPolicyBinding",
|
||||
"ServiceAccount",
|
||||
|
||||
@@ -51,9 +51,9 @@ const pacEvaluator = createRequire(import.meta.url)("../native/cicd/pac-status-e
|
||||
describe("YAML 组合的 CI/CD delivery authority", () => {
|
||||
test("组合 PaC 与 Gitea YAML,覆盖所有实际 consumer 且 id 唯一", () => {
|
||||
const catalog = readCicdDeliveryAuthorityCatalog();
|
||||
expect(catalog.consumers).toHaveLength(9);
|
||||
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(9);
|
||||
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01", "platform-infra-sub2rank-nc01"]) {
|
||||
expect(catalog.consumers).toHaveLength(10);
|
||||
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(10);
|
||||
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]) {
|
||||
const authority = resolveCicdDeliveryAuthority({ consumerId });
|
||||
expect(authority.kind).toBe("pac-pr-merge");
|
||||
expect(authority.mutationHintsAllowed).toBe(false);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { rootPath } from "./config";
|
||||
import { readYamlRecord } from "./platform-infra-ops-library";
|
||||
import { monitorResetScript, monitorStatusScript, monitorStatusSucceeded, runPlatformDbCommand } from "./platform-db";
|
||||
|
||||
const configPath = "config/platform-db/postgres-nc01.yaml";
|
||||
const parsed = readYamlRecord<Record<string, any>>(rootPath(configPath), "postgres-host-cluster");
|
||||
const pg = { objects: parsed.objects };
|
||||
const target = parsed.managedOperations.monitorReset;
|
||||
|
||||
describe("platform-db Monitor 专属空库入口", () => {
|
||||
test("plan 精确显示 YAML 目标且默认零写入", async () => {
|
||||
const result = await runPlatformDbCommand({} as never, ["postgres", "monitor", "plan", "--config", configPath]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
target: "web-probe-monitor",
|
||||
config: configPath,
|
||||
database: "web_probe_monitor",
|
||||
owner: "web_probe_monitor",
|
||||
schema: "monitor",
|
||||
secretRef: "platform-db/web-probe-monitor-nc01-db.env",
|
||||
mutation: false,
|
||||
valuesPrinted: false,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("PASSWORD");
|
||||
expect(JSON.stringify(result)).not.toContain("DATABASE_URL");
|
||||
});
|
||||
|
||||
test("reset 未确认时只返回计划", async () => {
|
||||
const result = await runPlatformDbCommand({} as never, ["postgres", "monitor", "reset", "--config", configPath]);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, mutation: false, database: "web_probe_monitor" });
|
||||
expect(result).not.toHaveProperty("confirmed");
|
||||
});
|
||||
|
||||
test("确认脚本只重建 Monitor database 并应用 central schema", () => {
|
||||
const script = monitorResetScript(pg as never, target);
|
||||
|
||||
expect(script).toContain("database='web_probe_monitor'");
|
||||
expect(script).toContain("owner='web_probe_monitor'");
|
||||
expect(script).toContain('drop database if exists "web_probe_monitor";');
|
||||
expect(script).toContain("create schema if not exists monitor;");
|
||||
expect(script).toContain('set role "web_probe_monitor";');
|
||||
expect(script).not.toContain("drop database agentrun_v02");
|
||||
expect(script).not.toContain("drop database decision_center");
|
||||
});
|
||||
|
||||
test("status/reset 验证 schema ready、业务 0 行与其他数据库 OID 不变", () => {
|
||||
const status = monitorStatusScript(pg as never, target);
|
||||
const reset = monitorResetScript(pg as never, target);
|
||||
|
||||
expect(status).toContain('\"schemaReady\":%s');
|
||||
expect(status).toContain('\"businessRows\":%s');
|
||||
expect(status).toContain('\"siblingDatabaseOids\":\"%s\"');
|
||||
expect(reset).toContain('[ "$before" != "$after" ]');
|
||||
expect(reset).toContain('[ "$rows" != "0" ]');
|
||||
expect(reset).toContain('\"siblingDatabasesUnchanged\":true');
|
||||
});
|
||||
|
||||
test("status ready=true 且远端退出 0 时成功", () => {
|
||||
expect(monitorStatusSucceeded(0, { ok: true, ready: true, schemaReady: true, empty: true, businessRows: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
test("status ready=false 时失败,即使远端误返回退出 0", () => {
|
||||
expect(monitorStatusSucceeded(0, { ok: false, ready: false, schemaReady: true, empty: false, businessRows: 1 })).toBe(false);
|
||||
expect(monitorStatusSucceeded(0, { ok: true, ready: false, schemaReady: false, empty: false, businessRows: null })).toBe(false);
|
||||
expect(monitorStatusSucceeded(1, { ok: false, ready: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ describe("NC01 Host PG Monitor declaration", () => {
|
||||
consumers: [{ scope: "hwlab-web-probe-monitor", secret: "hwlab-web-probe-monitor-db", key: "DATABASE_URL" }],
|
||||
});
|
||||
expect(monitorExport.render.format).not.toContain("web_probe_monitor:");
|
||||
expect(config.managedOperations.monitorReset).toEqual({ database: "web_probe_monitor", owner: "web_probe_monitor", schema: "monitor", secretRef: "platform-db/web-probe-monitor-nc01-db.env" });
|
||||
});
|
||||
|
||||
test("既有应用 role 保持原有独立 sourceRef", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
import { MONITOR_CENTRAL_MIGRATIONS, MONITOR_CENTRAL_SCHEMA_VERSION } from "./monitor-central-persistence";
|
||||
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
|
||||
import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, parseJsonOutput } from "./platform-infra-ops-library";
|
||||
|
||||
@@ -21,6 +22,13 @@ interface PlatformDbOptions {
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
interface MonitorResetConfig {
|
||||
database: string;
|
||||
owner: string;
|
||||
schema: string;
|
||||
secretRef: string;
|
||||
}
|
||||
|
||||
interface PostgresHostConfig {
|
||||
configPath: string;
|
||||
version: number;
|
||||
@@ -139,6 +147,7 @@ interface PostgresHostConfig {
|
||||
encryption: { enabled: boolean };
|
||||
};
|
||||
};
|
||||
managedOperations?: { monitorReset: MonitorResetConfig };
|
||||
}
|
||||
|
||||
interface PgHbaRule {
|
||||
@@ -300,6 +309,9 @@ export function platformDbHelp(): unknown {
|
||||
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`,
|
||||
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`,
|
||||
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`,
|
||||
"bun scripts/cli.ts platform-db postgres monitor plan --config config/platform-db/postgres-nc01.yaml",
|
||||
"bun scripts/cli.ts platform-db postgres monitor status --config config/platform-db/postgres-nc01.yaml",
|
||||
"bun scripts/cli.ts platform-db postgres monitor reset --config config/platform-db/postgres-nc01.yaml --confirm",
|
||||
],
|
||||
description: "Manage YAML-declared host-native PostgreSQL for UniDesk platform state. The PK01 declaration uses PostgreSQL 16 for Sub2API and does not deploy k3s.",
|
||||
safety: {
|
||||
@@ -307,6 +319,7 @@ export function platformDbHelp(): unknown {
|
||||
defaultMutation: false,
|
||||
exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.",
|
||||
apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.",
|
||||
monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.",
|
||||
redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.",
|
||||
noK3s: "PK01 PostgreSQL is host-systemd, not Docker or k3s.",
|
||||
},
|
||||
@@ -316,6 +329,11 @@ export function platformDbHelp(): unknown {
|
||||
export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [target, action = "plan"] = args;
|
||||
if (target !== "postgres") return unsupported(args);
|
||||
if (action === "monitor") {
|
||||
const monitorAction = args[2] ?? "plan";
|
||||
const options = parseOptions(args.slice(3));
|
||||
return await monitorCommand(config, monitorAction, options);
|
||||
}
|
||||
const options = parseOptions(args.slice(2));
|
||||
if (action === "plan") return await plan(config, options);
|
||||
if (action === "status") return await status(config, options);
|
||||
@@ -324,6 +342,53 @@ export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]
|
||||
return unsupported(args);
|
||||
}
|
||||
|
||||
async function monitorCommand(config: UniDeskConfig, action: string, options: PlatformDbOptions): Promise<Record<string, unknown>> {
|
||||
if (!(["plan", "status", "reset"] as const).includes(action as "plan" | "status" | "reset")) return unsupported(["postgres", "monitor", action]);
|
||||
const pg = readPostgresHostConfig(options.configPath);
|
||||
const target = requireMonitorResetConfig(pg);
|
||||
const base = {
|
||||
action: `platform-db-postgres-monitor-${action}`,
|
||||
target: "web-probe-monitor",
|
||||
config: pg.configPath,
|
||||
database: target.database,
|
||||
owner: target.owner,
|
||||
schema: target.schema,
|
||||
schemaVersion: MONITOR_CENTRAL_SCHEMA_VERSION,
|
||||
secretRef: target.secretRef,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
if (action === "plan" || (action === "reset" && !options.confirm)) {
|
||||
return {
|
||||
ok: true,
|
||||
...base,
|
||||
mutation: false,
|
||||
plan: monitorResetPlan(target),
|
||||
next: {
|
||||
status: `bun scripts/cli.ts platform-db postgres monitor status --config ${pg.configPath}`,
|
||||
confirm: `bun scripts/cli.ts platform-db postgres monitor reset --config ${pg.configPath} --confirm`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const script = action === "status" ? monitorStatusScript(pg, target) : monitorResetScript(pg, target);
|
||||
const capture = await runSshCommandCapture(config, pg.node.route, ["sh"], script);
|
||||
const parsed = parseJsonOutput(capture.stdout);
|
||||
const commandOk = action === "status"
|
||||
? monitorStatusSucceeded(capture.exitCode, parsed)
|
||||
: capture.exitCode === 0 && parsed?.ok === true;
|
||||
return {
|
||||
ok: commandOk,
|
||||
...base,
|
||||
mutation: action === "reset",
|
||||
...(action === "reset" ? { confirmed: true } : {}),
|
||||
state: parsed,
|
||||
remote: compactCapture(capture, { full: options.full || capture.exitCode !== 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function monitorStatusSucceeded(exitCode: number, parsed: Record<string, unknown> | null): boolean {
|
||||
return exitCode === 0 && parsed?.ok === true && parsed.ready === true;
|
||||
}
|
||||
|
||||
function unsupported(args: string[]): Record<string, unknown> {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -589,6 +654,7 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
|
||||
const objects = objectField(parsed, "objects", configPath);
|
||||
const exportsConfig = objectField(parsed, "exports", configPath);
|
||||
const backup = objectField(parsed, "backup", configPath);
|
||||
const managedOperations = parsed.managedOperations === undefined ? null : objectField(parsed, "managedOperations", configPath);
|
||||
const logicalDump = objectField(backup, "logicalDump", `${configPath}.backup`);
|
||||
const destination = objectField(logicalDump, "destination", `${configPath}.backup.logicalDump`);
|
||||
const encryption = objectField(logicalDump, "encryption", `${configPath}.backup.logicalDump`);
|
||||
@@ -618,6 +684,13 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
|
||||
databaseNames.add(database.name);
|
||||
}
|
||||
const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`));
|
||||
const monitorReset = managedOperations === null ? null : monitorResetConfig(objectField(managedOperations, "monitorReset", `${configPath}.managedOperations`), `${configPath}.managedOperations.monitorReset`);
|
||||
if (monitorReset !== null) {
|
||||
const database = databases.find((item) => item.name === monitorReset.database);
|
||||
if (monitorReset.database !== "web_probe_monitor" || monitorReset.owner !== "web_probe_monitor" || monitorReset.schema !== "monitor" || database?.owner !== monitorReset.owner) {
|
||||
throw new Error(`${configPath}.managedOperations.monitorReset is restricted to the dedicated web_probe_monitor database/schema`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
configPath: relativeConfigPath(configPath),
|
||||
version,
|
||||
@@ -729,6 +802,16 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
|
||||
},
|
||||
},
|
||||
},
|
||||
...(monitorReset === null ? {} : { managedOperations: { monitorReset } }),
|
||||
};
|
||||
}
|
||||
|
||||
function monitorResetConfig(item: Record<string, unknown>, path: string): MonitorResetConfig {
|
||||
return {
|
||||
database: pgIdentifier(stringField(item, "database", path), `${path}.database`),
|
||||
owner: pgIdentifier(stringField(item, "owner", path), `${path}.owner`),
|
||||
schema: pgIdentifier(stringField(item, "schema", path), `${path}.schema`),
|
||||
secretRef: stringField(item, "secretRef", path),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -988,6 +1071,91 @@ function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function requireMonitorResetConfig(pg: PostgresHostConfig): MonitorResetConfig {
|
||||
const target = pg.managedOperations?.monitorReset;
|
||||
if (target === undefined) throw new Error(`${pg.configPath}.managedOperations.monitorReset is required`);
|
||||
return target;
|
||||
}
|
||||
|
||||
function monitorResetPlan(target: MonitorResetConfig): string[] {
|
||||
return [
|
||||
`terminate connections only to ${target.database}`,
|
||||
`drop and recreate only ${target.database} owned by ${target.owner}`,
|
||||
`apply central Monitor schema ${MONITOR_CENTRAL_SCHEMA_VERSION} as ${target.owner}`,
|
||||
"verify schema ready and Monitor business row count is 0",
|
||||
"verify sibling YAML-declared database OIDs are unchanged",
|
||||
];
|
||||
}
|
||||
|
||||
export function monitorStatusScript(pg: PostgresHostConfig, target: MonitorResetConfig): string {
|
||||
const siblings = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => database.name).join(",");
|
||||
const siblingArray = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => sqlLiteral(database.name)).join(", ");
|
||||
return `${monitorScriptPrelude()}
|
||||
database=${shellQuote(target.database)}
|
||||
schema_version=${shellQuote(MONITOR_CENTRAL_SCHEMA_VERSION)}
|
||||
siblings=${shellQuote(siblings)}
|
||||
database_oid=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select oid from pg_database where datname = ${sqlLiteral(target.database)};")
|
||||
sibling_oids=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});")
|
||||
schema_ready=false
|
||||
schema_version_observed=""
|
||||
business_rows=""
|
||||
if [ -n "$database_oid" ]; then
|
||||
migration_table=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select coalesce(to_regclass('monitor.schema_migrations')::text, '');")
|
||||
if [ -n "$migration_table" ]; then
|
||||
schema_version_observed=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select coalesce((select version from monitor.schema_migrations order by applied_at desc limit 1), '');")
|
||||
fi
|
||||
if [ "$schema_version_observed" = "$schema_version" ]; then
|
||||
schema_ready=true
|
||||
business_rows=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select (select count(*) from monitor.runners) + (select count(*) from monitor.runs) + (select count(*) from monitor.run_payloads) + (select count(*) from monitor.findings) + (select count(*) from monitor.artifact_locators) + (select count(*) from monitor.timeline_events) + (select count(*) from monitor.runtime_metadata) + (select count(*) from monitor.import_manifests);")
|
||||
fi
|
||||
fi
|
||||
empty=false
|
||||
ready=false
|
||||
if [ "$schema_ready" = true ] && [ "$business_rows" = "0" ]; then empty=true; ready=true; fi
|
||||
printf '{"ok":%s,"databaseExists":%s,"schemaReady":%s,"empty":%s,"ready":%s,"databaseOid":"%s","schemaVersion":"%s","businessRows":%s,"siblingDatabaseOids":"%s"}\n' "$ready" "$([ -n "$database_oid" ] && printf true || printf false)" "$schema_ready" "$empty" "$ready" "$database_oid" "$schema_version_observed" "$([ -n "$business_rows" ] && printf '%s' "$business_rows" || printf null)" "$sibling_oids"
|
||||
if [ "$ready" != true ]; then exit 1; fi
|
||||
`;
|
||||
}
|
||||
|
||||
export function monitorResetScript(pg: PostgresHostConfig, target: MonitorResetConfig): string {
|
||||
const siblings = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => database.name).join(",");
|
||||
const siblingArray = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => sqlLiteral(database.name)).join(", ");
|
||||
const migrations = MONITOR_CENTRAL_MIGRATIONS.map((statement) => `${statement};`).join("\n");
|
||||
return `${monitorScriptPrelude()}
|
||||
database=${shellQuote(target.database)}
|
||||
owner=${shellQuote(target.owner)}
|
||||
siblings=${shellQuote(siblings)}
|
||||
before=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});")
|
||||
sudo -n runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d postgres <<'SQL'
|
||||
select pg_terminate_backend(pid) from pg_stat_activity where datname = ${sqlLiteral(target.database)} and pid <> pg_backend_pid();
|
||||
drop database if exists "${target.database}";
|
||||
create database "${target.database}" owner "${target.owner}" encoding 'UTF8' locale 'C.UTF-8' template template0;
|
||||
SQL
|
||||
sudo -n runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d "$database" <<'SQL'
|
||||
set role "${target.owner}";
|
||||
${migrations}
|
||||
insert into monitor.schema_migrations (version, applied_at) values (${sqlLiteral(MONITOR_CENTRAL_SCHEMA_VERSION)}, now()) on conflict (version) do nothing;
|
||||
SQL
|
||||
after=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});")
|
||||
rows=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select (select count(*) from monitor.runners) + (select count(*) from monitor.runs) + (select count(*) from monitor.run_payloads) + (select count(*) from monitor.findings) + (select count(*) from monitor.artifact_locators) + (select count(*) from monitor.timeline_events) + (select count(*) from monitor.runtime_metadata) + (select count(*) from monitor.import_manifests);")
|
||||
version=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select version from monitor.schema_migrations order by applied_at desc limit 1;")
|
||||
if [ "$before" != "$after" ] || [ "$rows" != "0" ] || [ "$version" != ${shellQuote(MONITOR_CENTRAL_SCHEMA_VERSION)} ]; then exit 1; fi
|
||||
printf '{"ok":true,"database":"%s","schema":"monitor","schemaVersion":"%s","businessRows":0,"siblingDatabasesUnchanged":true}\n' "$database" "$version"
|
||||
`;
|
||||
}
|
||||
|
||||
function monitorScriptPrelude(): string {
|
||||
return `set -eu
|
||||
if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then
|
||||
printf '{"ok":false,"error":"sudo-noninteractive-unavailable"}\n'
|
||||
exit 1
|
||||
fi`;
|
||||
}
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
function inspectSecrets(pg: PostgresHostConfig, materialize: boolean): SecretInspection {
|
||||
const root = secretRoot(pg);
|
||||
const materials = new Map<string, SecretMaterial>();
|
||||
|
||||
@@ -277,6 +277,7 @@ export interface GiteaMirrorRepository {
|
||||
repository: string;
|
||||
cloneUrl: string;
|
||||
branch: string;
|
||||
visibility: "public" | "private";
|
||||
};
|
||||
gitea: {
|
||||
owner: string;
|
||||
@@ -296,7 +297,7 @@ export interface GiteaMirrorRepository {
|
||||
readUrl: string;
|
||||
configRef: string;
|
||||
disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly";
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function readGiteaConfig(): GiteaConfig {
|
||||
@@ -653,7 +654,9 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
|
||||
const gitea = y.objectField(record, "gitea", path);
|
||||
const gitops = y.objectField(record, "gitops", path);
|
||||
const snapshot = y.objectField(record, "snapshot", path);
|
||||
const legacyGitMirror = y.objectField(record, "legacyGitMirror", path);
|
||||
const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined
|
||||
? null
|
||||
: y.objectField(record, "legacyGitMirror", path);
|
||||
return {
|
||||
key: y.stringField(record, "key", path),
|
||||
targetId: y.stringField(record, "targetId", path),
|
||||
@@ -661,6 +664,7 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
|
||||
repository: repositoryField(upstream, "repository", `${path}.upstream`),
|
||||
cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`),
|
||||
branch: y.stringField(upstream, "branch", `${path}.upstream`),
|
||||
visibility: upstream.visibility === undefined ? "public" : y.enumField(upstream, "visibility", `${path}.upstream`, ["public", "private"] as const),
|
||||
},
|
||||
gitea: {
|
||||
owner: y.stringField(gitea, "owner", `${path}.gitea`),
|
||||
@@ -676,7 +680,7 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
|
||||
snapshot: {
|
||||
prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`),
|
||||
},
|
||||
legacyGitMirror: {
|
||||
legacyGitMirror: legacyGitMirror === null ? null : {
|
||||
readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`),
|
||||
configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`),
|
||||
disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const),
|
||||
@@ -816,6 +820,7 @@ function validateConfig(gitea: GiteaConfig): void {
|
||||
const readUrl = new URL(repo.gitea.readUrl);
|
||||
if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`);
|
||||
if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`);
|
||||
if (repo.upstream.visibility === "private" && repo.gitea.publicRead) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.publicRead must be false for a private upstream`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ test("owning YAML renders one child Application and the complete durable bridge
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ServiceAccount",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
"ValidatingAdmissionPolicyBinding",
|
||||
"ServiceAccount",
|
||||
@@ -91,7 +92,7 @@ test("owning YAML renders one child Application and the complete durable bridge
|
||||
.filter((item) => item.kind === "Role" && item.metadata?.name === "unidesk-pac-consumer-runner")
|
||||
.map((item) => item.metadata.namespace)
|
||||
.sort();
|
||||
expect(pacReadOnlyNamespaces).toEqual(["agentrun-ci", "devops-infra"]);
|
||||
expect(pacReadOnlyNamespaces).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]);
|
||||
const candidate = documents.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "gitea-github-sync-candidate");
|
||||
const activeConfig = documents.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "gitea-github-sync-config");
|
||||
const bridge = documents.find((item) => item.kind === "Deployment" && item.metadata?.name === "gitea-github-sync");
|
||||
|
||||
@@ -515,7 +515,8 @@ for i, repo in enumerate(repos):
|
||||
key = repo["key"]
|
||||
source_branch = repo["upstream"]["branch"]
|
||||
gitops_branch = repo["gitops"]["branch"]
|
||||
branches = [source_branch] + ([] if gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch])
|
||||
sync_gitops_from_upstream = repo["gitops"].get("flushDisposition") != "gitea-writeback"
|
||||
branches = [source_branch] + ([] if not sync_gitops_from_upstream or gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch])
|
||||
work = f"$tmp/repo-{i}.git"
|
||||
gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path
|
||||
script += [
|
||||
@@ -525,13 +526,14 @@ for i, repo in enumerate(repos):
|
||||
f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc",
|
||||
f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc",
|
||||
]
|
||||
if gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch":
|
||||
if sync_gitops_from_upstream and gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch":
|
||||
script.append(f"if [ \"$fetch_rc\" -eq 0 ]; then gitops_sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(gitops_branch)} 2>/dev/null || true); else gitops_sha=''; fi")
|
||||
script.append(f"if [ -n \"$gitops_sha\" ] && [ \"$push_rc\" -eq 0 ]; then git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $gitops_sha:refs/heads/{shlex.quote(gitops_branch)} >>$tmp/{key}.push.out 2>>$tmp/{key}.push.err; gitops_push_rc=$?; else gitops_push_rc=0; fi; printf '%s' \"$gitops_push_rc\" >$tmp/{key}.gitops-push.rc")
|
||||
else:
|
||||
script.append(f"printf '%s' '0' >$tmp/{key}.gitops-push.rc")
|
||||
script.append(f"if [ \"$push_rc\" -eq 0 ]; then printf '%s %s\\n' \"$sha\" \"$snapshot_ref\" >$tmp/{key}.bundle; fi")
|
||||
meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"]})
|
||||
legacy = repo.get("legacyGitMirror") or {}
|
||||
meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "gitopsUpstreamSync": sync_gitops_from_upstream, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": legacy.get("readUrl"), "legacyDisposition": legacy.get("disposition", "native-gitea-only")})
|
||||
open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n")
|
||||
json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
PY
|
||||
@@ -621,7 +623,8 @@ for repo in repos:
|
||||
gitops = repo["gitops"]["branch"]
|
||||
gitea_probe_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path
|
||||
script.append(f"git -c http.extraHeader=\"$GITEA_AUTH_HEADER\" ls-remote {shlex.quote(gitea_probe_url)} refs/heads/{shlex.quote(branch)} '{shlex.quote(repo['snapshot']['prefix'])}/*' refs/heads/{shlex.quote(gitops)} >$tmp/{key}.ls.out 2>$tmp/{key}.ls.err")
|
||||
meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"], "legacyDisposition": repo["legacyGitMirror"]["disposition"]})
|
||||
legacy = repo.get("legacyGitMirror") or {}
|
||||
meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": legacy.get("readUrl"), "legacyDisposition": legacy.get("disposition", "native-gitea-only")})
|
||||
open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n")
|
||||
json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False)
|
||||
PY
|
||||
|
||||
@@ -1483,13 +1483,14 @@ function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Rec
|
||||
key: repo.key,
|
||||
upstreamRepository: repo.upstream.repository,
|
||||
upstreamBranch: repo.upstream.branch,
|
||||
upstreamVisibility: repo.upstream.visibility,
|
||||
giteaOwner: repo.gitea.owner,
|
||||
giteaRepo: repo.gitea.name,
|
||||
mirrorMode: repo.gitea.mirrorMode,
|
||||
readUrl: repo.gitea.readUrl,
|
||||
snapshotPrefix: repo.snapshot.prefix,
|
||||
legacyReadUrl: repo.legacyGitMirror.readUrl,
|
||||
legacyDisposition: repo.legacyGitMirror.disposition,
|
||||
legacyReadUrl: repo.legacyGitMirror?.readUrl ?? null,
|
||||
legacyDisposition: repo.legacyGitMirror?.disposition ?? "native-gitea-only",
|
||||
sourceAuthority: "gitea",
|
||||
rootUrlMatches: repo.gitea.readUrl.startsWith(gitea.app.server.rootUrl),
|
||||
};
|
||||
|
||||
@@ -5,12 +5,6 @@ import { readPacAdmissionContract } from "./platform-infra-pac-provenance";
|
||||
export interface PacConsumerRbacDesiredIdentity {
|
||||
readonly configSha256: string;
|
||||
readonly namespaces: readonly string[];
|
||||
readonly executionServiceAccounts: readonly {
|
||||
readonly consumerId: string;
|
||||
readonly namespace: string;
|
||||
readonly name: string;
|
||||
readonly automountServiceAccountToken: false;
|
||||
}[];
|
||||
}
|
||||
|
||||
const pacConsumerReadOnlyRules = [
|
||||
@@ -25,7 +19,7 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string {
|
||||
const namespaces = [...new Set(consumers.map((consumer) => consumer.namespace))];
|
||||
if (namespaces.length === 0) return "";
|
||||
const configSha256 = pacConsumerRbacDesiredIdentity(targetId).configSha256;
|
||||
const objects: Record<string, unknown>[] = namespaces.flatMap((namespace) => {
|
||||
const objects = namespaces.flatMap((namespace) => {
|
||||
const labels = {
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
@@ -51,25 +45,6 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string {
|
||||
},
|
||||
];
|
||||
});
|
||||
objects.push(...consumers.filter((consumer) => consumer.manageExecutionServiceAccount).map((consumer) => ({
|
||||
apiVersion: "v1",
|
||||
kind: "ServiceAccount",
|
||||
metadata: {
|
||||
name: consumer.executionServiceAccountName,
|
||||
namespace: consumer.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
"unidesk.ai/pac-rbac-contract": "admission-provenance-required",
|
||||
"unidesk.ai/pac-consumer": consumer.id,
|
||||
},
|
||||
annotations: {
|
||||
"argocd.argoproj.io/sync-wave": "-2",
|
||||
"unidesk.ai/effective-config-sha256": configSha256,
|
||||
},
|
||||
},
|
||||
automountServiceAccountToken: false,
|
||||
})));
|
||||
return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -85,18 +60,8 @@ export function pacConsumerRbacDesiredIdentity(targetId: string): PacConsumerRba
|
||||
roleRef: pacConsumerDefaultRoleRef,
|
||||
},
|
||||
}));
|
||||
const executionServiceAccounts = consumers
|
||||
.filter((consumer) => consumer.manageExecutionServiceAccount)
|
||||
.map((consumer) => ({
|
||||
consumerId: consumer.id,
|
||||
namespace: consumer.namespace,
|
||||
name: consumer.executionServiceAccountName,
|
||||
automountServiceAccountToken: false as const,
|
||||
}))
|
||||
.sort((left, right) => `${left.namespace}/${left.name}`.localeCompare(`${right.namespace}/${right.name}`));
|
||||
return {
|
||||
configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, executionServiceAccounts, contract: "admission-provenance-required-v2" })).digest("hex")}`,
|
||||
configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, contract: "admission-provenance-required-v1" })).digest("hex")}`,
|
||||
namespaces,
|
||||
executionServiceAccounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
toState: "started",
|
||||
}]);
|
||||
expect(contract.child.enabled).toBe(false);
|
||||
expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01"]);
|
||||
expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]);
|
||||
const items = documents(renderPacAdmissionDesiredFragment("NC01"));
|
||||
expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]);
|
||||
const policy = items[0];
|
||||
@@ -90,6 +90,7 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
expect(expressions).toContain("agentrun-nc01-v02-tekton-runner");
|
||||
expect(expressions).toContain("sub2rank-nc01-tekton-runner");
|
||||
expect(expressions).not.toContain('serviceAccountName == "default"');
|
||||
expect(expressions).toContain("selfmedia-nc01-tekton-runner");
|
||||
expect(messages).toContain("execution ServiceAccount");
|
||||
expect(expressions).toContain(contract.child.parentUidAnnotation);
|
||||
expect(expressions).toContain("oldObject");
|
||||
@@ -125,33 +126,29 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
expect(queueGuard.expression.match(/pipelines-as-code-watcher/gu)).toHaveLength(1);
|
||||
expect(queueGuard.expression.match(/agentrun-nc01-v02-tekton-runner/gu)).toHaveLength(2);
|
||||
expect(queueGuard.expression.match(/sub2rank-nc01-tekton-runner/gu)).toHaveLength(2);
|
||||
expect(queueGuard.expression.match(/selfmedia-nc01-tekton-runner/gu)).toHaveLength(2);
|
||||
expect(queueGuard.expression).not.toContain("admission-pac-v1:");
|
||||
expect(queueGuard.expression).not.toContain('== "Cancelled"');
|
||||
});
|
||||
|
||||
test("required consumers have one automatic desired owner per namespace and no Tekton mutation verbs", () => {
|
||||
test("required consumer default RBAC has one automatic desired owner per namespace and no Tekton mutation verbs", () => {
|
||||
const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01"));
|
||||
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "ServiceAccount"]);
|
||||
expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra", "devops-infra"]);
|
||||
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]);
|
||||
expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]);
|
||||
for (const role of rbac.filter((item) => item.kind === "Role")) {
|
||||
expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1");
|
||||
expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256);
|
||||
const verbs = role.rules.flatMap((rule: any) => rule.verbs);
|
||||
for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb);
|
||||
}
|
||||
const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount");
|
||||
expect(executionServiceAccount).toMatchObject({
|
||||
metadata: { name: "sub2rank-nc01-tekton-runner", namespace: "devops-infra" },
|
||||
automountServiceAccountToken: false,
|
||||
});
|
||||
expect(executionServiceAccount.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-2");
|
||||
const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind);
|
||||
expect(desiredKinds).toEqual([
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ServiceAccount",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
"ValidatingAdmissionPolicyBinding",
|
||||
"ServiceAccount",
|
||||
@@ -164,7 +161,6 @@ test("live admission evaluator requires exact desired hashes, observed generatio
|
||||
const contract = readPacAdmissionContract();
|
||||
const admission = documents(renderPacAdmissionDesiredFragment("NC01"));
|
||||
const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01"));
|
||||
const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount");
|
||||
const policy = { ...admission[0], metadata: { ...admission[0].metadata, uid: "policy-uid", generation: 2, creationTimestamp: "2026-01-01T00:00:00Z" }, status: { observedGeneration: 2, typeChecking: { expressionWarnings: [] } } };
|
||||
const binding = { ...admission[1], metadata: { ...admission[1].metadata, uid: "binding-uid", creationTimestamp: "2026-01-01T00:00:01Z" } };
|
||||
const expected = {
|
||||
@@ -175,14 +171,9 @@ test("live admission evaluator requires exact desired hashes, observed generatio
|
||||
controllerIdentity: contract.controllerUsername,
|
||||
configSha256: policy.metadata.annotations["unidesk.ai/effective-config-sha256"],
|
||||
rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"],
|
||||
managedExecutionServiceAccounts: pacConsumerRbacDesiredIdentity("NC01").executionServiceAccounts,
|
||||
};
|
||||
const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, executionServiceAccounts: [executionServiceAccount], expected };
|
||||
const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected };
|
||||
expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, executionServiceAccounts: [] })).toMatchObject({
|
||||
ready: false,
|
||||
reasons: expect.arrayContaining(["execution-serviceaccount-missing:platform-infra-sub2rank-nc01"]),
|
||||
});
|
||||
expect(evaluator.evaluatePacAdmissionState({
|
||||
...input,
|
||||
policy: {
|
||||
@@ -377,6 +368,15 @@ test("history evaluates admission and default RBAC in each consumer namespace",
|
||||
expect(remote).toContain("'delivery plan source commit does not match the selected PipelineRun'");
|
||||
expect(remote).not.toContain("'g14-ci-plan structured evidence is missing'");
|
||||
expect(remote).not.toContain("for (const consumer of consumers) consumer.admissionState = admissionState");
|
||||
expect(remote).toContain("consumer_bootstrap_state()");
|
||||
expect(remote).toContain('get serviceaccount "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME"');
|
||||
expect(remote).toContain('get rolebinding "$UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME"');
|
||||
expect(remote).toContain('get secret "$UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME"');
|
||||
expect(remote).toContain("sa.automountServiceAccountToken === false");
|
||||
expect(remote).toContain("const expectedKeys = ['password', 'url', 'username']");
|
||||
expect(remote).toContain("passwordDecoded: false");
|
||||
expect(remote).not.toContain("Buffer.from(argoSecret.data.password");
|
||||
expect(remote).not.toContain("--from-literal=password=");
|
||||
});
|
||||
|
||||
test("id-specific PaC debug keeps fixture failures but does not dump every passing check", () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface PacAdmissionConsumerContract {
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly markerValue: string;
|
||||
readonly executionServiceAccountName: string;
|
||||
readonly manageExecutionServiceAccount: boolean;
|
||||
readonly gitOps: { readonly repoUrl: string; readonly targetRevision: string };
|
||||
}
|
||||
|
||||
@@ -99,7 +98,6 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
|
||||
pipelineRunPrefix: required(consumer.pipelineRunPrefix, `consumers[${index}].pipelineRunPrefix`),
|
||||
markerValue: required(value.markerValue, `consumers[${index}].deliveryProvenance.markerValue`),
|
||||
executionServiceAccountName: required(value.executionServiceAccountName, `consumers[${index}].deliveryProvenance.executionServiceAccountName`),
|
||||
manageExecutionServiceAccount: optionalBoolean(value.manageExecutionServiceAccount, `consumers[${index}].deliveryProvenance.manageExecutionServiceAccount`, false),
|
||||
gitOps: {
|
||||
repoUrl: required(gitOps.repoUrl, `consumers[${index}].deliveryProvenance.gitOps.repoUrl`),
|
||||
targetRevision: required(gitOps.targetRevision, `consumers[${index}].deliveryProvenance.gitOps.targetRevision`),
|
||||
@@ -400,9 +398,3 @@ function literal<T extends string | boolean>(value: unknown, path: string, expec
|
||||
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, path: string, fallback: boolean): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "boolean") throw new Error(`${path} must be boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,50 @@ roleRef:
|
||||
EOF
|
||||
}
|
||||
|
||||
pac_repository_secret_manifest() {
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const input = fs.readFileSync(0);
|
||||
const split = input.indexOf(0);
|
||||
if (split < 0) process.exit(2);
|
||||
const token = input.subarray(0, split);
|
||||
const webhook = input.subarray(split + 1);
|
||||
const data = {};
|
||||
data[process.env.UNIDESK_PAC_TOKEN_KEY] = token.toString("base64");
|
||||
data[process.env.UNIDESK_PAC_WEBHOOK_SECRET_KEY] = webhook.toString("base64");
|
||||
process.stdout.write(JSON.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: { name: process.env.UNIDESK_PAC_SECRET_NAME, namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE },
|
||||
type: "Opaque",
|
||||
data,
|
||||
}));
|
||||
'
|
||||
}
|
||||
|
||||
argo_repository_secret_manifest() {
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const token = fs.readFileSync(0);
|
||||
const encoded = (value) => Buffer.from(value, "utf8").toString("base64");
|
||||
process.stdout.write(JSON.stringify({
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: process.env.UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME,
|
||||
namespace: process.env.UNIDESK_PAC_ARGO_NAMESPACE,
|
||||
labels: { "argocd.argoproj.io/secret-type": "repository" },
|
||||
},
|
||||
type: "Opaque",
|
||||
data: {
|
||||
url: encoded(process.env.UNIDESK_PAC_ARGO_REPOSITORY_URL || ""),
|
||||
username: encoded(process.env.UNIDESK_PAC_ARGO_REPOSITORY_USERNAME || ""),
|
||||
password: token.toString("base64"),
|
||||
},
|
||||
}));
|
||||
'
|
||||
}
|
||||
|
||||
apply_release() {
|
||||
tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_RELEASE_MANIFEST_B64" | base64 -d > "$tmp"
|
||||
@@ -238,15 +282,25 @@ apply_action() {
|
||||
else
|
||||
consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
fi
|
||||
if [ -n "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64:-}" ]; then
|
||||
runner_tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64" | base64 -d >"$runner_tmp"
|
||||
kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-runner-bootstrap" -f "$runner_tmp" >/dev/null
|
||||
rm -f "$runner_tmp"
|
||||
fi
|
||||
token=$(ensure_token)
|
||||
test -n "$token"
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \
|
||||
--from-literal="$UNIDESK_PAC_TOKEN_KEY=$token" \
|
||||
--from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \
|
||||
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||
{ printf '%s' "$token"; printf '\0'; printf '%s' "$UNIDESK_PAC_WEBHOOK_SECRET"; } \
|
||||
| pac_repository_secret_manifest \
|
||||
| kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-repository-secret" -f - >/dev/null
|
||||
repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
argo_bootstrap=false
|
||||
if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then
|
||||
if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then
|
||||
printf '%s' "$token" \
|
||||
| argo_repository_secret_manifest \
|
||||
| kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-argocd-repository" -f - >/dev/null
|
||||
fi
|
||||
argo_tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp"
|
||||
kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null
|
||||
@@ -291,28 +345,10 @@ admission_provenance_state() {
|
||||
export UNIDESK_PAC_DEFAULT_CAN_MUTATE="$default_can_mutate"
|
||||
node - "$policy_file" "$binding_file" "$role_file" "$role_binding_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const { evaluatePacAdmissionState } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH);
|
||||
function read(path) {
|
||||
try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; }
|
||||
}
|
||||
let managedExecutionServiceAccounts = [];
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON || '[]');
|
||||
if (Array.isArray(parsed)) managedExecutionServiceAccounts = parsed;
|
||||
} catch {}
|
||||
const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10);
|
||||
const kubectlTimeoutMs = Number.isInteger(waitTimeoutSeconds) && waitTimeoutSeconds > 0 ? waitTimeoutSeconds * 1000 : null;
|
||||
const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => {
|
||||
if (kubectlTimeoutMs === null || !item || typeof item.namespace !== 'string' || typeof item.name !== 'string') return {};
|
||||
const result = cp.spawnSync('kubectl', ['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'], {
|
||||
encoding: 'utf8',
|
||||
timeout: kubectlTimeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0) return {};
|
||||
try { return JSON.parse(result.stdout || '{}'); } catch { return {}; }
|
||||
});
|
||||
process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
|
||||
policy: read(process.argv[2]),
|
||||
binding: read(process.argv[3]),
|
||||
@@ -320,7 +356,6 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
|
||||
roleBinding: read(process.argv[5]),
|
||||
namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE || null,
|
||||
defaultCanMutate: process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'unknown' ? null : process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'true',
|
||||
executionServiceAccounts,
|
||||
expected: {
|
||||
targetId: process.env.UNIDESK_PAC_TARGET_ID || '',
|
||||
policyName: process.env.UNIDESK_PAC_ADMISSION_POLICY_NAME || '',
|
||||
@@ -329,7 +364,6 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({
|
||||
controllerIdentity: process.env.UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY || '',
|
||||
configSha256: process.env.UNIDESK_PAC_ADMISSION_CONFIG_SHA256 || '',
|
||||
rbacConfigSha256: process.env.UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256 || '',
|
||||
managedExecutionServiceAccounts,
|
||||
},
|
||||
})));
|
||||
NODE
|
||||
@@ -490,7 +524,6 @@ function kubectlJson(args, fallback, context) {
|
||||
let admissionPolicy = null;
|
||||
let admissionBinding = null;
|
||||
const admissionRbac = new Map();
|
||||
const admissionExecutionServiceAccounts = new Map();
|
||||
|
||||
function defaultCanMutate(namespace) {
|
||||
const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10);
|
||||
@@ -528,18 +561,6 @@ function admissionStateForConsumer(consumer) {
|
||||
});
|
||||
}
|
||||
const rbac = admissionRbac.get(consumer.namespace);
|
||||
const managedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts : [];
|
||||
const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => {
|
||||
const key = `${item.namespace || ''}/${item.name || ''}`;
|
||||
if (!admissionExecutionServiceAccounts.has(key)) {
|
||||
admissionExecutionServiceAccounts.set(key, kubectlJson(
|
||||
['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'],
|
||||
{},
|
||||
`${consumer.id}:execution-serviceaccount:${key}`,
|
||||
));
|
||||
}
|
||||
return admissionExecutionServiceAccounts.get(key);
|
||||
});
|
||||
return evaluatePacAdmissionState({
|
||||
policy: admissionPolicy,
|
||||
binding: admissionBinding,
|
||||
@@ -547,7 +568,6 @@ function admissionStateForConsumer(consumer) {
|
||||
roleBinding: rbac.roleBinding,
|
||||
namespace: consumer.namespace,
|
||||
defaultCanMutate: rbac.defaultCanMutate,
|
||||
executionServiceAccounts,
|
||||
expected: {
|
||||
targetId: expected.targetId,
|
||||
policyName: expected.policyName,
|
||||
@@ -556,7 +576,6 @@ function admissionStateForConsumer(consumer) {
|
||||
controllerIdentity: expected.controllerIdentity,
|
||||
configSha256: expected.configSha256,
|
||||
rbacConfigSha256: expected.rbacConfigSha256,
|
||||
managedExecutionServiceAccounts,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1322,11 +1341,121 @@ process.stdout.write(JSON.stringify(rows));
|
||||
NODE
|
||||
}
|
||||
|
||||
consumer_bootstrap_state() {
|
||||
if [ -z "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME:-}" ] && [ -z "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then
|
||||
printf '{"configured":false,"ready":null,"reasons":["consumer-bootstrap-not-declared"],"valuesPrinted":false}'
|
||||
return
|
||||
fi
|
||||
sa_file=$(mktemp)
|
||||
role_binding_file=$(mktemp)
|
||||
argo_secret_file=$(mktemp)
|
||||
if [ -n "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME:-}" ]; then
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get serviceaccount "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME" -o json >"$sa_file" 2>/dev/null || printf '{}' >"$sa_file"
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding "$UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME" -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file"
|
||||
else
|
||||
printf '{}' >"$sa_file"
|
||||
printf '{}' >"$role_binding_file"
|
||||
fi
|
||||
if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then
|
||||
kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get secret "$UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME" -o json >"$argo_secret_file" 2>/dev/null || printf '{}' >"$argo_secret_file"
|
||||
else
|
||||
printf '{}' >"$argo_secret_file"
|
||||
fi
|
||||
node - "$sa_file" "$role_binding_file" "$argo_secret_file" <<'NODE'
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
function read(path) {
|
||||
try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; }
|
||||
}
|
||||
function fingerprint(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
const sa = read(process.argv[2]);
|
||||
const roleBinding = read(process.argv[3]);
|
||||
const argoSecret = read(process.argv[4]);
|
||||
const namespace = process.env.UNIDESK_PAC_TARGET_NAMESPACE || '';
|
||||
const runnerName = process.env.UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME || '';
|
||||
const roleBindingName = process.env.UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME || '';
|
||||
const argoNamespace = process.env.UNIDESK_PAC_ARGO_NAMESPACE || '';
|
||||
const argoSecretName = process.env.UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME || '';
|
||||
const argoUrl = process.env.UNIDESK_PAC_ARGO_REPOSITORY_URL || '';
|
||||
const reasons = [];
|
||||
const runnerConfigured = runnerName.length > 0;
|
||||
const saExists = !runnerConfigured || sa.metadata?.name === runnerName;
|
||||
const automountDisabled = !runnerConfigured || sa.automountServiceAccountToken === false;
|
||||
const subjects = Array.isArray(roleBinding.subjects) ? roleBinding.subjects : [];
|
||||
const roleBindingExact = !runnerConfigured || (
|
||||
roleBinding.metadata?.name === roleBindingName
|
||||
&& roleBinding.metadata?.namespace === namespace
|
||||
&& subjects.length === 1
|
||||
&& subjects[0]?.kind === 'ServiceAccount'
|
||||
&& subjects[0]?.name === runnerName
|
||||
&& subjects[0]?.namespace === namespace
|
||||
&& roleBinding.roleRef?.apiGroup === 'rbac.authorization.k8s.io'
|
||||
&& roleBinding.roleRef?.kind === 'Role'
|
||||
&& roleBinding.roleRef?.name === 'unidesk-pac-consumer-runner'
|
||||
);
|
||||
if (!saExists) reasons.push('runner-service-account-missing');
|
||||
if (!automountDisabled) reasons.push('runner-service-account-automount-not-false');
|
||||
if (!roleBindingExact) reasons.push('runner-rolebinding-drifted-or-missing');
|
||||
const argoConfigured = argoSecretName.length > 0;
|
||||
const argoExists = !argoConfigured || (argoSecret.metadata?.name === argoSecretName && argoSecret.metadata?.namespace === argoNamespace);
|
||||
const argoLabelReady = !argoConfigured || argoSecret.metadata?.labels?.['argocd.argoproj.io/secret-type'] === 'repository';
|
||||
const keys = Object.keys(argoSecret.data || {}).sort();
|
||||
const expectedKeys = ['password', 'url', 'username'];
|
||||
const keysReady = !argoConfigured || JSON.stringify(keys) === JSON.stringify(expectedKeys);
|
||||
let observedUrlFingerprint = null;
|
||||
if (argoConfigured && typeof argoSecret.data?.url === 'string') {
|
||||
try { observedUrlFingerprint = fingerprint(Buffer.from(argoSecret.data.url, 'base64').toString('utf8')); } catch {}
|
||||
}
|
||||
const expectedUrlFingerprint = argoConfigured ? fingerprint(argoUrl) : null;
|
||||
const urlReady = !argoConfigured || observedUrlFingerprint === expectedUrlFingerprint;
|
||||
const passwordPresent = !argoConfigured || (typeof argoSecret.data?.password === 'string' && argoSecret.data.password.length > 0);
|
||||
if (!argoExists) reasons.push('argocd-repository-secret-missing');
|
||||
if (!argoLabelReady) reasons.push('argocd-repository-secret-label-drifted');
|
||||
if (!keysReady) reasons.push('argocd-repository-secret-keys-drifted');
|
||||
if (!urlReady) reasons.push('argocd-repository-secret-url-drifted');
|
||||
if (!passwordPresent) reasons.push('argocd-repository-secret-password-missing');
|
||||
process.stdout.write(JSON.stringify({
|
||||
configured: runnerConfigured || argoConfigured,
|
||||
ready: reasons.length === 0,
|
||||
runner: {
|
||||
configured: runnerConfigured,
|
||||
serviceAccount: runnerName || null,
|
||||
exists: saExists,
|
||||
automountServiceAccountToken: sa.metadata?.name === runnerName ? sa.automountServiceAccountToken ?? null : null,
|
||||
automountDisabled,
|
||||
roleBinding: roleBindingName || null,
|
||||
roleBindingExact,
|
||||
},
|
||||
argoRepository: {
|
||||
configured: argoConfigured,
|
||||
secretName: argoSecretName || null,
|
||||
exists: argoExists,
|
||||
labelReady: argoLabelReady,
|
||||
expectedKeys,
|
||||
observedKeys: keys,
|
||||
keysReady,
|
||||
expectedUrlFingerprint,
|
||||
observedUrlFingerprint,
|
||||
urlReady,
|
||||
passwordPresent,
|
||||
passwordDecoded: false,
|
||||
},
|
||||
reasons,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
NODE
|
||||
rm -f "$sa_file" "$role_binding_file" "$argo_secret_file"
|
||||
}
|
||||
|
||||
status_action() {
|
||||
crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true)
|
||||
controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0")
|
||||
repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME")
|
||||
prepare_admission_provenance_state
|
||||
consumer_bootstrap=$(consumer_bootstrap_state)
|
||||
bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")')
|
||||
pipelines=$(pipeline_rows)
|
||||
latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")')
|
||||
export UNIDESK_PAC_TARGET_PIPELINERUN="$latest"
|
||||
@@ -1351,9 +1480,10 @@ NODE
|
||||
runtime=$(json_normalize "$(runtime_summary)")
|
||||
diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime")
|
||||
admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")')
|
||||
diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node <<'NODE'
|
||||
diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node <<'NODE'
|
||||
const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}');
|
||||
const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}');
|
||||
const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || '{}');
|
||||
if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
...diagnostics,
|
||||
@@ -1364,16 +1494,28 @@ if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionPro
|
||||
admissionProvenance,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
} else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
...diagnostics,
|
||||
ok: false,
|
||||
code: 'pac-consumer-bootstrap-not-ready',
|
||||
phase: 'consumer-bootstrap-not-ready',
|
||||
hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted',
|
||||
admissionProvenance,
|
||||
consumerBootstrap,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, valuesPrinted: false }));
|
||||
process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false }));
|
||||
}
|
||||
NODE
|
||||
)
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \
|
||||
"$( [ -n "$crd" ] && echo true || echo false )" \
|
||||
"$(json_string "$controller_ready")" \
|
||||
"$UNIDESK_PAC_ADMISSION_STATE_JSON" \
|
||||
"$consumer_bootstrap" \
|
||||
"$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \
|
||||
"$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \
|
||||
"$(json_string "$UNIDESK_PAC_GITEA_REPO")" \
|
||||
|
||||
@@ -198,6 +198,10 @@ describe("PaC source artifact CLI contract", () => {
|
||||
const sourceArtifact = consumers.find((item) => item.extends === template && item.variables?.NODE === "NC01")?.sourceArtifact;
|
||||
expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 });
|
||||
}
|
||||
for (const id of ["platform-infra-sub2rank-nc01", "selfmedia-nc01"]) {
|
||||
const sourceArtifact = consumers.find((item) => item.id === id)?.sourceArtifact;
|
||||
expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("undeclared JD01 source artifact owner fails closed", () => {
|
||||
|
||||
@@ -18,9 +18,10 @@ import { renderedCliResult } from "./agentrun/render";
|
||||
import { stableJsonSha256, stableJsonValue } from "./stable-json";
|
||||
import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance";
|
||||
import { renderSub2RankDesiredPipeline, sub2RankOwningSourceRemote } from "./platform-infra-sub2rank-pipeline";
|
||||
import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer";
|
||||
|
||||
export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime";
|
||||
export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime";
|
||||
export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable";
|
||||
|
||||
@@ -454,7 +455,9 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree
|
||||
? renderAgentRunDesiredPipeline(binding)
|
||||
: sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? renderHwlabDesiredPipeline(binding, sourceWorktree)
|
||||
: renderSub2RankDesiredPipeline(binding);
|
||||
: sourceArtifact.renderer === "sub2rank-platform-service"
|
||||
? renderSub2RankDesiredPipeline(binding)
|
||||
: renderSelfMediaPipeline(binding, sourceWorktree);
|
||||
const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? rendered.pipeline
|
||||
: withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance);
|
||||
@@ -525,6 +528,19 @@ function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWor
|
||||
};
|
||||
}
|
||||
|
||||
function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record<string, unknown>; provenance: PacSourceArtifactProvenance } {
|
||||
const rendered = renderSelfMediaDesiredPipeline(binding, sourceWorktree);
|
||||
return {
|
||||
pipeline: rendered.pipeline,
|
||||
provenance: {
|
||||
configRef: rendered.configRef,
|
||||
effectiveConfigSha256: rendered.effectiveConfigSha256,
|
||||
renderer: "selfmedia-runtime",
|
||||
mode: "embedded-pipeline-spec",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record<string, unknown> {
|
||||
const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-"));
|
||||
const temporarySource = resolve(temporaryRoot, "source");
|
||||
@@ -594,10 +610,17 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec
|
||||
name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`,
|
||||
namespace: binding.consumer.namespace,
|
||||
annotations: pipelineRunAnnotations(binding, provenance),
|
||||
labels: pipelineRunLabels(binding, provenance.renderer === "sub2rank-platform-service" ? "sub2rank" : "agentrun"),
|
||||
labels: pipelineRunLabels(
|
||||
binding,
|
||||
provenance.renderer === "sub2rank-platform-service"
|
||||
? "sub2rank"
|
||||
: provenance.renderer === "selfmedia-runtime"
|
||||
? "selfmedia"
|
||||
: "agentrun",
|
||||
),
|
||||
},
|
||||
spec: {
|
||||
timeouts: { pipeline: binding.repository.params.pipeline_timeout },
|
||||
...(binding.repository.params.pipeline_timeout === undefined ? {} : { timeouts: { pipeline: binding.repository.params.pipeline_timeout } }),
|
||||
pipelineSpec: desiredSpec,
|
||||
taskRunTemplate: taskRunTemplate(binding),
|
||||
params: pipelineRunParams(binding, desiredSpec),
|
||||
@@ -661,29 +684,42 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank"): Record<string, string> {
|
||||
return partOf === "agentrun"
|
||||
? {
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia"): Record<string, string> {
|
||||
if (partOf === "agentrun") {
|
||||
return {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/lane": "v0.2",
|
||||
"agentrun.pikastech.local/node": binding.consumer.node,
|
||||
"agentrun.pikastech.local/source-commit": "{{ revision }}",
|
||||
"agentrun.pikastech.local/trigger": "pipelines-as-code",
|
||||
}
|
||||
: partOf === "hwlab" ? {
|
||||
};
|
||||
}
|
||||
if (partOf === "selfmedia") {
|
||||
return {
|
||||
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
|
||||
"app.kubernetes.io/part-of": "hwlab",
|
||||
"app.kubernetes.io/part-of": "selfmedia-factory",
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/gitops-target": binding.consumer.lane,
|
||||
"hwlab.pikastech.local/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/trigger": "pipelines-as-code",
|
||||
} : {
|
||||
"selfmedia.pikapython.com/source-commit": "{{ revision }}",
|
||||
"selfmedia.pikapython.com/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
if (partOf === "sub2rank") {
|
||||
return {
|
||||
"app.kubernetes.io/name": "sub2rank",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
"unidesk.ai/node": binding.consumer.node,
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"unidesk.ai/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
return {
|
||||
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
|
||||
"app.kubernetes.io/part-of": "hwlab",
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/gitops-target": binding.consumer.lane,
|
||||
"hwlab.pikastech.local/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
|
||||
function taskRunTemplate(binding: PacSourceArtifactBinding): Record<string, unknown> {
|
||||
@@ -828,9 +864,8 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string {
|
||||
if (binding.consumer.sourceArtifact.renderer === "agentrun-control-plane") {
|
||||
return resolveAgentRunLaneTarget({ node: binding.consumer.node, lane: binding.consumer.lane }).spec.source.worktreeRemote;
|
||||
}
|
||||
if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") {
|
||||
return sub2RankOwningSourceRemote();
|
||||
}
|
||||
if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") return sub2RankOwningSourceRemote();
|
||||
if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node);
|
||||
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved");
|
||||
return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl;
|
||||
}
|
||||
@@ -946,7 +981,10 @@ function assertPipelineIdentity(pipeline: Record<string, unknown>, binding: PacS
|
||||
function provenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
|
||||
const provenance = pipelineProvenanceFromManifest(manifest);
|
||||
if (provenance === null) return null;
|
||||
if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane" && provenance.renderer !== "sub2rank-platform-service") return null;
|
||||
if (provenance.renderer !== "agentrun-control-plane"
|
||||
&& provenance.renderer !== "hwlab-runtime-lane"
|
||||
&& provenance.renderer !== "sub2rank-platform-service"
|
||||
&& provenance.renderer !== "selfmedia-runtime") return null;
|
||||
if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null;
|
||||
return provenance as PacSourceArtifactProvenance;
|
||||
}
|
||||
|
||||
@@ -154,18 +154,26 @@ interface PacConsumer {
|
||||
path: string;
|
||||
destinationNamespace: string;
|
||||
automated: boolean;
|
||||
repositoryCredential: {
|
||||
secretName: string;
|
||||
username: string;
|
||||
} | null;
|
||||
} | null;
|
||||
deliveryProvenance: {
|
||||
required: true;
|
||||
markerValue: string;
|
||||
executionServiceAccountName: string;
|
||||
manageExecutionServiceAccount: boolean;
|
||||
gitOps: { repoUrl: string; targetRevision: string };
|
||||
} | null;
|
||||
repositoryRef: string;
|
||||
closeoutGitOpsMirrorFlush: boolean;
|
||||
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
|
||||
sourceArtifact: PacSourceArtifactSpec | null;
|
||||
runnerServiceAccount: {
|
||||
name: string;
|
||||
automountServiceAccountToken: false;
|
||||
roleBindingName: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface CommonOptions {
|
||||
@@ -499,6 +507,7 @@ function parseConsumer(consumer: Record<string, unknown>, path: string, defaultR
|
||||
const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`;
|
||||
const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path);
|
||||
const deliveryProvenance = consumer.deliveryProvenance === undefined ? null : parseConsumerDeliveryProvenance(y.objectField(consumer, "deliveryProvenance", path), `${path}.deliveryProvenance`);
|
||||
const runnerServiceAccount = consumer.runnerServiceAccount === undefined ? null : y.objectField(consumer, "runnerServiceAccount", path);
|
||||
return {
|
||||
id,
|
||||
node: y.stringField(consumer, "node", path),
|
||||
@@ -515,15 +524,32 @@ function parseConsumer(consumer: Record<string, unknown>, path: string, defaultR
|
||||
path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`),
|
||||
destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`),
|
||||
automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`),
|
||||
repositoryCredential: argoBootstrap.repositoryCredential === undefined ? null : (() => {
|
||||
const credential = y.objectField(argoBootstrap, "repositoryCredential", `${path}.argoBootstrap`);
|
||||
return {
|
||||
secretName: y.kubernetesNameField(credential, "secretName", `${path}.argoBootstrap.repositoryCredential`),
|
||||
username: y.stringField(credential, "username", `${path}.argoBootstrap.repositoryCredential`),
|
||||
};
|
||||
})(),
|
||||
},
|
||||
deliveryProvenance,
|
||||
repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef,
|
||||
closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
|
||||
closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const),
|
||||
sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`),
|
||||
runnerServiceAccount: runnerServiceAccount === null ? null : {
|
||||
name: y.kubernetesNameField(runnerServiceAccount, "name", `${path}.runnerServiceAccount`),
|
||||
automountServiceAccountToken: parseFalse(runnerServiceAccount, "automountServiceAccountToken", `${path}.runnerServiceAccount`),
|
||||
roleBindingName: y.kubernetesNameField(runnerServiceAccount, "roleBindingName", `${path}.runnerServiceAccount`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseFalse(record: Record<string, unknown>, key: string, path: string): false {
|
||||
if (y.booleanField(record, key, path) !== false) throw new Error(`${path}.${key} must be false`);
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: string): PacConsumer["deliveryProvenance"] {
|
||||
const required = y.booleanField(value, "required", path);
|
||||
if (!required) return null;
|
||||
@@ -532,9 +558,6 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
|
||||
required: true,
|
||||
markerValue: y.stringField(value, "markerValue", path),
|
||||
executionServiceAccountName: y.kubernetesNameField(value, "executionServiceAccountName", path),
|
||||
manageExecutionServiceAccount: value.manageExecutionServiceAccount === undefined
|
||||
? false
|
||||
: y.booleanField(value, "manageExecutionServiceAccount", path),
|
||||
gitOps: {
|
||||
repoUrl: urlField(gitOps, "repoUrl", `${path}.gitOps`),
|
||||
targetRevision: y.stringField(gitOps, "targetRevision", `${path}.gitOps`),
|
||||
@@ -544,7 +567,7 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
|
||||
|
||||
function parseSourceArtifact(value: Record<string, unknown>, path: string): PacSourceArtifactSpec {
|
||||
const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const);
|
||||
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service"] as const);
|
||||
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime"] as const);
|
||||
const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`);
|
||||
const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`);
|
||||
const taskRunTemplate = y.objectField(value, "taskRunTemplate", path);
|
||||
@@ -553,6 +576,7 @@ function parseSourceArtifact(value: Record<string, unknown>, path: string): PacS
|
||||
if (renderer === "agentrun-control-plane" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer agentrun-control-plane requires embedded-pipeline-spec`);
|
||||
if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`);
|
||||
if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`);
|
||||
if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`);
|
||||
return {
|
||||
mode,
|
||||
renderer,
|
||||
@@ -789,6 +813,22 @@ function validateConfig(config: PacConfig): void {
|
||||
|| consumer.argoBootstrap.targetRevision !== consumer.deliveryProvenance.gitOps.targetRevision)) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.gitOps must match argoBootstrap source identity`);
|
||||
}
|
||||
if (consumer.runnerServiceAccount !== null && consumer.runnerServiceAccount.name !== consumer.deliveryProvenance.executionServiceAccountName) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount.name must match deliveryProvenance.executionServiceAccountName`);
|
||||
}
|
||||
}
|
||||
if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`);
|
||||
}
|
||||
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") {
|
||||
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`);
|
||||
if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`);
|
||||
}
|
||||
if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`);
|
||||
if (repository.params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.repositories.${repository.id}.params.gitops_secret_name must match secretName`);
|
||||
if (repository.params.gitops_username !== consumer.argoBootstrap.repositoryCredential.username) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo and Tekton Gitea usernames must match`);
|
||||
if (repository.params.gitops_read_url !== consumer.argoBootstrap.repoUrl) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo repoUrl must match params.gitops_read_url`);
|
||||
}
|
||||
}
|
||||
if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`);
|
||||
@@ -1244,8 +1284,10 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
|
||||
UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0",
|
||||
UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64: Buffer.from(runnerServiceAccountManifest(consumer), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME: consumer.runnerServiceAccount?.name ?? "",
|
||||
UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME: consumer.runnerServiceAccount?.roleBindingName ?? "",
|
||||
UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256: rbacIdentity.configSha256,
|
||||
UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON: JSON.stringify(rbacIdentity.executionServiceAccounts),
|
||||
UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName,
|
||||
UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName,
|
||||
UNIDESK_PAC_ADMISSION_VERSION: admissionIdentity.version,
|
||||
@@ -1260,6 +1302,9 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
|
||||
UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace,
|
||||
UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication,
|
||||
UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME: consumer.argoBootstrap?.repositoryCredential?.secretName ?? "",
|
||||
UNIDESK_PAC_ARGO_REPOSITORY_USERNAME: consumer.argoBootstrap?.repositoryCredential?.username ?? "",
|
||||
UNIDESK_PAC_ARGO_REPOSITORY_URL: consumer.argoBootstrap?.repoUrl ?? "",
|
||||
UNIDESK_PAC_PART_OF: pacPartOf(consumer.id),
|
||||
UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec),
|
||||
};
|
||||
@@ -1289,7 +1334,6 @@ function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record<st
|
||||
bindingName: admissionIdentity.bindingName,
|
||||
configSha256: admissionIdentity.configSha256,
|
||||
rbacConfigSha256: rbacIdentity.configSha256,
|
||||
managedExecutionServiceAccounts: rbacIdentity.executionServiceAccounts,
|
||||
markerAnnotation: pac.deliveryProvenance.markerAnnotation,
|
||||
markerValue: consumer.deliveryProvenance.markerValue,
|
||||
executionServiceAccountName: consumer.deliveryProvenance.executionServiceAccountName,
|
||||
@@ -1307,9 +1351,36 @@ function pacPartOf(consumerId: string): string {
|
||||
if (consumerId === "unidesk-host") return "unidesk-host";
|
||||
if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel";
|
||||
if (consumerId.startsWith("hwlab-")) return "hwlab";
|
||||
if (consumerId.startsWith("selfmedia-")) return "selfmedia-factory";
|
||||
return "agentrun";
|
||||
}
|
||||
|
||||
function runnerServiceAccountManifest(consumer: PacConsumer): string {
|
||||
const runner = consumer.runnerServiceAccount;
|
||||
if (runner === null) return "";
|
||||
const labels = {
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"app.kubernetes.io/part-of": pacPartOf(consumer.id),
|
||||
"unidesk.ai/pac-runner": consumer.id,
|
||||
};
|
||||
const objects = [
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ServiceAccount",
|
||||
metadata: { name: runner.name, namespace: consumer.namespace, labels },
|
||||
automountServiceAccountToken: runner.automountServiceAccountToken,
|
||||
},
|
||||
{
|
||||
apiVersion: "rbac.authorization.k8s.io/v1",
|
||||
kind: "RoleBinding",
|
||||
metadata: { name: runner.roleBindingName, namespace: consumer.namespace, labels },
|
||||
subjects: [{ kind: "ServiceAccount", name: runner.name, namespace: consumer.namespace }],
|
||||
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "unidesk-pac-consumer-runner" },
|
||||
},
|
||||
];
|
||||
return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
||||
}
|
||||
|
||||
function argoBootstrapManifest(consumer: PacConsumer): string {
|
||||
const bootstrap = consumer.argoBootstrap;
|
||||
if (bootstrap === null) return "";
|
||||
@@ -1430,6 +1501,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
|
||||
const runtime = record(payload.runtime);
|
||||
const diagnostics = record(payload.diagnostics);
|
||||
const admissionProvenance = record(payload.admissionProvenance);
|
||||
const consumerBootstrap = record(payload.consumerBootstrap);
|
||||
const webhooks = arrayRecords(payload.webhooks);
|
||||
const rawArtifact = record(payload.artifact);
|
||||
const sourceObservation = record(rawArtifact.sourceObservation);
|
||||
@@ -1446,7 +1518,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
|
||||
};
|
||||
const pipelineGate = pipelineRunGate(latest);
|
||||
return {
|
||||
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false,
|
||||
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false && consumerBootstrap.ready !== false,
|
||||
crdPresent: payload.crdPresent === true,
|
||||
controllerReady: payload.controllerReady,
|
||||
repositoryCondition: payload.repositoryCondition,
|
||||
@@ -1463,6 +1535,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
|
||||
runtime,
|
||||
diagnostics,
|
||||
admissionProvenance,
|
||||
consumerBootstrap,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -1634,6 +1707,11 @@ function consumerObservationSummary(consumer: PacConsumer): Record<string, unkno
|
||||
argoNamespace: consumer.argoNamespace,
|
||||
argoApplication: consumer.argoApplication,
|
||||
repositoryRef: consumer.repositoryRef,
|
||||
runnerServiceAccount: consumer.runnerServiceAccount,
|
||||
argoRepositoryCredential: consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined ? null : {
|
||||
secretName: consumer.argoBootstrap.repositoryCredential.secretName,
|
||||
usernameConfigured: consumer.argoBootstrap.repositoryCredential.username.length > 0,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -1685,6 +1763,8 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
|
||||
argoNamespace: consumer.argoNamespace,
|
||||
argoApplication: consumer.argoApplication,
|
||||
repositoryRef: consumer.repositoryRef,
|
||||
runnerServiceAccount: consumer.runnerServiceAccount,
|
||||
argoRepositoryCredential: consumer.argoRepositoryCredential,
|
||||
},
|
||||
secrets: result.secrets,
|
||||
policy: result.policy,
|
||||
@@ -1748,6 +1828,7 @@ function compactStatusSummary(summary: Record<string, unknown>): Record<string,
|
||||
},
|
||||
pipelineRunGate: summary.pipelineRunGate,
|
||||
admissionProvenance: summary.admissionProvenance,
|
||||
consumerBootstrap: summary.consumerBootstrap,
|
||||
sourceObservation: summary.sourceObservation,
|
||||
artifact: {
|
||||
imageStatus: artifact.imageStatus,
|
||||
@@ -1969,6 +2050,9 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
||||
const argo = record(summary.argo);
|
||||
const diagnostics = record(summary.diagnostics);
|
||||
const admissionProvenance = record(summary.admissionProvenance);
|
||||
const consumerBootstrap = record(summary.consumerBootstrap);
|
||||
const bootstrapRunner = record(consumerBootstrap.runner);
|
||||
const bootstrapArgo = record(consumerBootstrap.argoRepository);
|
||||
const sourceObservation = record(summary.sourceObservation);
|
||||
const deliveryPlan = record(sourceObservation.plan);
|
||||
const repository = record(summary.repository);
|
||||
@@ -1989,6 +2073,13 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
||||
` admission-provenance: ready=${boolText(admissionProvenance.ready)} policy=${stringValue(admissionProvenance.policyName)} binding=${stringValue(admissionProvenance.bindingName)} active-after=${stringValue(admissionProvenance.activeAfter)} default-can-mutate=${boolText(admissionProvenance.defaultCanMutate)}`,
|
||||
` admission-reasons: ${Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons.join(",") || "-" : "-"}`,
|
||||
]),
|
||||
...(consumerBootstrap.configured !== true
|
||||
? [" consumer-bootstrap: not-declared"]
|
||||
: [
|
||||
` consumer-bootstrap: ready=${boolText(consumerBootstrap.ready)} runner=${stringValue(bootstrapRunner.serviceAccount)} exists=${boolText(bootstrapRunner.exists)} automount-disabled=${boolText(bootstrapRunner.automountDisabled)} rolebinding-exact=${boolText(bootstrapRunner.roleBindingExact)}`,
|
||||
` argocd-repository-secret: name=${stringValue(bootstrapArgo.secretName)} exists=${boolText(bootstrapArgo.exists)} label=${boolText(bootstrapArgo.labelReady)} keys=${boolText(bootstrapArgo.keysReady)} url=${boolText(bootstrapArgo.urlReady)} password-present=${boolText(bootstrapArgo.passwordPresent)} password-decoded=${boolText(bootstrapArgo.passwordDecoded)}`,
|
||||
` consumer-bootstrap-reasons: ${Array.isArray(consumerBootstrap.reasons) ? consumerBootstrap.reasons.join(",") || "-" : "-"}`,
|
||||
]),
|
||||
"",
|
||||
"GITEA HOOKS",
|
||||
...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))),
|
||||
|
||||
+167
-35
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { rootPath } from "./config";
|
||||
@@ -39,23 +40,36 @@ interface SecretDistributionConfig {
|
||||
version: number;
|
||||
kind: "unidesk-secret-distribution";
|
||||
metadata: { id: string; owner: string; relatedIssues: number[] };
|
||||
sources: { root: string; files: SourceFileConfig[] };
|
||||
sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] };
|
||||
targets: DistributionTarget[];
|
||||
kubernetesSecrets: KubernetesSecretConfig[];
|
||||
}
|
||||
|
||||
interface SourceFileConfig {
|
||||
interface SourceCreateIfMissingConfig {
|
||||
enabled: boolean;
|
||||
values: Record<string, string>;
|
||||
randomHex: Record<string, number>;
|
||||
randomBase64Url: Record<string, { bytes: number; prefix: string }>;
|
||||
}
|
||||
|
||||
interface EnvSourceFileConfig {
|
||||
sourceRef: string;
|
||||
type: "env";
|
||||
requiredKeys: string[];
|
||||
createIfMissing: {
|
||||
enabled: boolean;
|
||||
values: Record<string, string>;
|
||||
randomHex: Record<string, number>;
|
||||
randomBase64Url: Record<string, { bytes: number; prefix: string }>;
|
||||
};
|
||||
required: true;
|
||||
createIfMissing: SourceCreateIfMissingConfig;
|
||||
}
|
||||
|
||||
interface RawSourceFileConfig {
|
||||
sourceRef: string;
|
||||
type: "raw-file";
|
||||
requiredKeys: ["contents"];
|
||||
required: boolean;
|
||||
createIfMissing: SourceCreateIfMissingConfig;
|
||||
}
|
||||
|
||||
type SourceFileConfig = EnvSourceFileConfig | RawSourceFileConfig;
|
||||
|
||||
interface DistributionTarget {
|
||||
id: string;
|
||||
route: string;
|
||||
@@ -80,8 +94,10 @@ interface SecretDataMapping {
|
||||
|
||||
interface SourceMaterial {
|
||||
sourceRef: string;
|
||||
type: SourceFileConfig["type"];
|
||||
sourcePath: string;
|
||||
exists: boolean;
|
||||
required: boolean;
|
||||
requiredKeys: string[];
|
||||
presentKeys: string[];
|
||||
missingKeys: string[];
|
||||
@@ -190,7 +206,7 @@ function parseOptions(args: string[]): SecretsOptions {
|
||||
function plan(options: SecretsOptions): Record<string, unknown> {
|
||||
const distribution = readSecretDistributionConfig(options.configPath);
|
||||
assertSelectedTargets(distribution, options);
|
||||
const sources = inspectSources(distribution, false);
|
||||
const sources = inspectSources(distribution, false, selectedSourceRefs(distribution, options));
|
||||
const desired = desiredSecrets(distribution, options, sources);
|
||||
return {
|
||||
ok: sources.ok && desired.every((secret) => secret.missingKeys.length === 0),
|
||||
@@ -200,7 +216,7 @@ function plan(options: SecretsOptions): Record<string, unknown> {
|
||||
localSources: sourceSummary(sources),
|
||||
desiredSecrets: desired.map(desiredSecretSummary),
|
||||
policy: {
|
||||
sourceAuthority: "local YAML-declared sourceRef files under sources.root",
|
||||
sourceAuthority: "local YAML-declared managed and external sourceRef files",
|
||||
runtimeReverseEngineering: false,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
@@ -233,7 +249,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise<Rec
|
||||
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
||||
};
|
||||
}
|
||||
const sources = inspectSources(distribution, true);
|
||||
const sources = inspectSources(distribution, true, selectedSourceRefs(distribution, options));
|
||||
if (!sources.ok) return { ok: false, action: "secrets-sync", mode: "blocked-local-sources", mutation: true, config: configSummary(distribution, options), localSources: sourceSummary(sources) };
|
||||
const desired = desiredSecrets(distribution, options, sources);
|
||||
const missing = desired.flatMap((secret) => secret.missingKeys);
|
||||
@@ -257,7 +273,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise<Rec
|
||||
async function status(config: UniDeskConfig, options: SecretsOptions): Promise<Record<string, unknown>> {
|
||||
const distribution = readSecretDistributionConfig(options.configPath);
|
||||
assertSelectedTargets(distribution, options);
|
||||
const sources = inspectSources(distribution, false);
|
||||
const sources = inspectSources(distribution, false, selectedSourceRefs(distribution, options));
|
||||
const desired = desiredSecrets(distribution, options, sources);
|
||||
const perTarget = await Promise.all(groupDesiredSecretsByTarget(desired).map(async (group) => await statusTargetSecrets(config, group.target, group.secrets, options)));
|
||||
return {
|
||||
@@ -293,6 +309,9 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig
|
||||
sources: {
|
||||
root: stringField(sources, "root", `${label}.sources`),
|
||||
files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)),
|
||||
externalFiles: sources.externalFiles === undefined
|
||||
? []
|
||||
: arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)),
|
||||
},
|
||||
targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)),
|
||||
kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)),
|
||||
@@ -301,7 +320,7 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseSourceFile(record: Record<string, unknown>, path: string): SourceFileConfig {
|
||||
function parseSourceFile(record: Record<string, unknown>, path: string): EnvSourceFileConfig {
|
||||
const type = stringField(record, "type", path);
|
||||
if (type !== "env") throw new Error(`${path}.type must be env`);
|
||||
const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path);
|
||||
@@ -310,6 +329,7 @@ function parseSourceFile(record: Record<string, unknown>, path: string): SourceF
|
||||
sourceRef: sourceRefField(record, "sourceRef", path),
|
||||
type,
|
||||
requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)),
|
||||
required: true,
|
||||
createIfMissing: {
|
||||
enabled: createRaw.enabled === undefined ? false : booleanField(createRaw, "enabled", `${path}.createIfMissing`),
|
||||
values: createRaw.values === undefined ? {} : stringMapField(createRaw, "values", `${path}.createIfMissing`),
|
||||
@@ -319,6 +339,35 @@ function parseSourceFile(record: Record<string, unknown>, path: string): SourceF
|
||||
};
|
||||
}
|
||||
|
||||
function parseRawSourceFile(record: Record<string, unknown>, path: string): RawSourceFileConfig {
|
||||
const type = stringField(record, "type", path);
|
||||
if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`);
|
||||
const createRaw = objectField(record, "createIfMissing", path);
|
||||
const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`);
|
||||
const randomHex = createRaw.randomHex === undefined
|
||||
? {}
|
||||
: { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) };
|
||||
const randomBase64Url = createRaw.randomBase64Url === undefined
|
||||
? {}
|
||||
: { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) };
|
||||
if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`);
|
||||
const generatorCount = Object.keys(randomHex).length + Object.keys(randomBase64Url).length;
|
||||
if (enabled && generatorCount !== 1) throw new Error(`${path}.createIfMissing must declare exactly one of randomHex or randomBase64Url when enabled`);
|
||||
if (!enabled && generatorCount !== 0) throw new Error(`${path}.createIfMissing generators require enabled: true`);
|
||||
return {
|
||||
sourceRef: externalSourceRefField(record, "sourceRef", path),
|
||||
type,
|
||||
requiredKeys: ["contents"],
|
||||
required: booleanField(record, "required", path),
|
||||
createIfMissing: {
|
||||
enabled,
|
||||
values: {},
|
||||
randomHex,
|
||||
randomBase64Url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, path: string): DistributionTarget {
|
||||
return {
|
||||
id: simpleId(stringField(record, "id", path), `${path}.id`),
|
||||
@@ -338,17 +387,22 @@ function parseKubernetesSecret(record: Record<string, unknown>, path: string): K
|
||||
secretName: kubernetesNameField(record, "secretName", path),
|
||||
type,
|
||||
data: arrayOfRecords(record.data, `${path}.data`).map((item, index) => ({
|
||||
sourceRef: sourceRefField(item, "sourceRef", `${path}.data[${index}]`),
|
||||
sourceKey: envKeyField(item, "sourceKey", `${path}.data[${index}]`),
|
||||
sourceRef: declaredSourceRefField(item, "sourceRef", `${path}.data[${index}]`),
|
||||
sourceKey: sourceDataKeyField(item, "sourceKey", `${path}.data[${index}]`),
|
||||
targetKey: kubernetesSecretKeyField(item, "targetKey", `${path}.data[${index}]`),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function validateDistributionConfig(config: SecretDistributionConfig): void {
|
||||
if (config.sources.files.length === 0) throw new Error(`${config.configPath}.sources.files must not be empty`);
|
||||
const sourceFiles = allSourceFiles(config);
|
||||
if (sourceFiles.length === 0) throw new Error(`${config.configPath}.sources must declare files or externalFiles`);
|
||||
if (config.targets.length === 0) throw new Error(`${config.configPath}.targets must not be empty`);
|
||||
const sources = new Map(config.sources.files.map((item) => [item.sourceRef, item]));
|
||||
const sources = new Map<string, SourceFileConfig>();
|
||||
for (const source of sourceFiles) {
|
||||
if (sources.has(source.sourceRef)) throw new Error(`${config.configPath}.sources declares duplicate sourceRef ${source.sourceRef}`);
|
||||
sources.set(source.sourceRef, source);
|
||||
}
|
||||
const targets = new Set(config.targets.map((item) => item.id));
|
||||
const targetSecrets = new Set<string>();
|
||||
for (const secret of config.kubernetesSecrets) {
|
||||
@@ -360,20 +414,25 @@ function validateDistributionConfig(config: SecretDistributionConfig): void {
|
||||
for (const item of secret.data) {
|
||||
const source = sources.get(item.sourceRef);
|
||||
if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`);
|
||||
if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps ${item.sourceRef}.${item.sourceKey}, but that key is not listed in sources.files.requiredKeys`);
|
||||
if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`);
|
||||
if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`);
|
||||
targetKeys.add(item.targetKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inspectSources(config: SecretDistributionConfig, materialize: boolean): SourceInspection {
|
||||
function allSourceFiles(config: SecretDistributionConfig): SourceFileConfig[] {
|
||||
return [...config.sources.files, ...config.sources.externalFiles];
|
||||
}
|
||||
|
||||
function inspectSources(config: SecretDistributionConfig, materialize: boolean, selectedRefs: Set<string>): SourceInspection {
|
||||
const root = secretRoot(config);
|
||||
const materials = new Map<string, SourceMaterial>();
|
||||
const entries = config.sources.files.map((source) => {
|
||||
const sourcePath = join(root, source.sourceRef);
|
||||
const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => {
|
||||
const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef);
|
||||
const exists = existsSync(sourcePath);
|
||||
const existing = exists ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {};
|
||||
const existingText = exists ? readFileSync(sourcePath, "utf8") : "";
|
||||
const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {};
|
||||
const next = { ...existing };
|
||||
const generatedKeys: string[] = [];
|
||||
const unmaterializedGeneratedKeys: string[] = [];
|
||||
@@ -402,11 +461,30 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean):
|
||||
const missingBefore = source.requiredKeys.filter((key) => existing[key] === undefined || existing[key].length === 0);
|
||||
const missingKeys = source.requiredKeys.filter((key) => next[key] === undefined || next[key].length === 0);
|
||||
const action = missingKeys.length > 0 ? "blocked" : !exists ? "create" : missingBefore.length > 0 ? "update" : "none";
|
||||
if (materialize && action !== "blocked" && (action === "create" || action === "update")) writeEnvFile(sourcePath, next);
|
||||
if (materialize && action !== "blocked" && (action === "create" || action === "update")) {
|
||||
if (source.type === "env") writeEnvFile(sourcePath, next);
|
||||
else writeRawFile(sourcePath, next.contents);
|
||||
}
|
||||
const materialized = materialize && action !== "blocked" && (action === "create" || action === "update");
|
||||
const presence = materialized || (exists && existingText.length > 0);
|
||||
const bytes = materialized
|
||||
? source.type === "env"
|
||||
? Buffer.byteLength(readFileSync(sourcePath, "utf8"), "utf8")
|
||||
: Buffer.byteLength(next.contents, "utf8")
|
||||
: exists
|
||||
? Buffer.byteLength(existingText, "utf8")
|
||||
: null;
|
||||
const fingerprint = missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0
|
||||
? source.type === "raw-file"
|
||||
? fingerprintRawFileContent(next.contents)
|
||||
: fingerprintValues(next, source.requiredKeys)
|
||||
: null;
|
||||
const material: SourceMaterial = {
|
||||
sourceRef: source.sourceRef,
|
||||
type: source.type,
|
||||
sourcePath,
|
||||
exists,
|
||||
required: source.required,
|
||||
requiredKeys: source.requiredKeys,
|
||||
presentKeys: source.requiredKeys.filter((key) => next[key] !== undefined && next[key].length > 0),
|
||||
missingKeys,
|
||||
@@ -414,11 +492,22 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean):
|
||||
generatedKeys,
|
||||
unmaterializedGeneratedKeys,
|
||||
values: next,
|
||||
fingerprint: missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0 ? fingerprintValues(next, source.requiredKeys) : null,
|
||||
fingerprint,
|
||||
};
|
||||
materials.set(source.sourceRef, material);
|
||||
if (source.type === "raw-file") {
|
||||
return {
|
||||
sourceRef: source.sourceRef,
|
||||
type: source.type,
|
||||
presence,
|
||||
bytes,
|
||||
fingerprint,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sourceRef: source.sourceRef,
|
||||
type: source.type,
|
||||
sourcePath: redactRepoPath(sourcePath),
|
||||
exists,
|
||||
requiredKeys: source.requiredKeys,
|
||||
@@ -432,7 +521,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean):
|
||||
};
|
||||
});
|
||||
return {
|
||||
ok: entries.every((entry) => (entry.missingKeys as string[]).length === 0),
|
||||
ok: Array.from(materials.values()).every((material) => !material.required || material.missingKeys.length === 0),
|
||||
root: redactRepoPath(root),
|
||||
entries,
|
||||
materials,
|
||||
@@ -489,6 +578,13 @@ function selectedTargets(config: SecretDistributionConfig, options: SecretsOptio
|
||||
.filter((target) => options.targetId === null || target.id === options.targetId);
|
||||
}
|
||||
|
||||
function selectedSourceRefs(config: SecretDistributionConfig, options: SecretsOptions): Set<string> {
|
||||
const targetIds = new Set(selectedTargets(config, options).map((target) => target.id));
|
||||
return new Set(config.kubernetesSecrets
|
||||
.filter((secret) => targetIds.has(secret.targetId))
|
||||
.flatMap((secret) => secret.data.map((item) => item.sourceRef)));
|
||||
}
|
||||
|
||||
async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise<Record<string, unknown>> {
|
||||
if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" };
|
||||
const yaml = renderSecretManifest(target, secrets);
|
||||
@@ -561,21 +657,21 @@ else
|
||||
apply_rc=1
|
||||
fi
|
||||
python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY'
|
||||
import base64, json, sys
|
||||
import base64, json, os, sys
|
||||
ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2])
|
||||
def text(path, limit=5000):
|
||||
def byte_count(path):
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
||||
return os.path.getsize(path)
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return 0
|
||||
payload = {
|
||||
"ok": ns_rc == 0 and apply_rc == 0,
|
||||
"namespace": "${target.namespace}",
|
||||
"secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")),
|
||||
"valuesPrinted": False,
|
||||
"steps": {
|
||||
"namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[3]), "stderr": text(sys.argv[4])},
|
||||
"apply": {"exitCode": apply_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])},
|
||||
"namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True},
|
||||
"apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True},
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
@@ -645,13 +741,17 @@ function groupDesiredSecretsByTarget(secrets: DesiredSecret[]): Array<{ target:
|
||||
}
|
||||
|
||||
function configSummary(config: SecretDistributionConfig, options: SecretsOptions): Record<string, unknown> {
|
||||
const sourceRefs = selectedSourceRefs(config, options);
|
||||
return {
|
||||
path: config.configPath,
|
||||
metadata: config.metadata,
|
||||
root: redactRepoPath(secretRoot(config)),
|
||||
scope: options.scope,
|
||||
targetId: options.targetId,
|
||||
sources: config.sources.files.map((item) => ({ sourceRef: item.sourceRef, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })),
|
||||
sources: [
|
||||
...config.sources.files.filter((item) => sourceRefs.has(item.sourceRef)).map((item) => ({ sourceRef: item.sourceRef, type: item.type, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })),
|
||||
...config.sources.externalFiles.filter((item) => sourceRefs.has(item.sourceRef)).map((item) => ({ sourceRef: item.sourceRef, type: item.type, required: item.required, createIfMissing: item.createIfMissing.enabled })),
|
||||
],
|
||||
targets: config.targets.filter((target) => (options.scope === null || target.scope === options.scope) && (options.targetId === null || target.id === options.targetId)).map(targetSummary),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
@@ -748,6 +848,16 @@ function writeEnvFile(path: string, values: Record<string, string>): void {
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
function writeRawFile(path: string, value: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(path, value, { encoding: "utf8", mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
function fingerprintRawFileContent(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
function quoteEnv(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value;
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
@@ -837,8 +947,30 @@ function sourceRefField(obj: Record<string, unknown>, key: string, path: string)
|
||||
return value;
|
||||
}
|
||||
|
||||
function envKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
return envKeyValue(stringField(obj, key, path), `${path}.${key}`);
|
||||
function externalSourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (value.includes("\0") || value.split("/").includes("..")) throw new Error(`${path}.${key} must not contain NUL or parent traversal`);
|
||||
if (value.startsWith("~/") && value.length > 2) return value;
|
||||
if (isAbsolute(value)) return value;
|
||||
throw new Error(`${path}.${key} must be an absolute path or a ~/ home path`);
|
||||
}
|
||||
|
||||
function declaredSourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (value.startsWith("~/") || isAbsolute(value)) return externalSourceRefField(obj, key, path);
|
||||
return sourceRefField(obj, key, path);
|
||||
}
|
||||
|
||||
function externalSourcePath(sourceRef: string): string {
|
||||
if (sourceRef.startsWith("~/")) return join(homedir(), sourceRef.slice(2));
|
||||
if (isAbsolute(sourceRef)) return sourceRef;
|
||||
throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`);
|
||||
}
|
||||
|
||||
function sourceDataKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function envKeyValue(value: string, path: string): string {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
|
||||
export const selfMediaConfigPath = rootPath("config", "selfmedia.yaml");
|
||||
export const selfMediaConfigRef = "config/selfmedia.yaml";
|
||||
|
||||
export interface SelfMediaDeliveryTarget {
|
||||
readonly id: string;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly route: string;
|
||||
readonly ci: {
|
||||
readonly namespace: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly serviceAccountName: string;
|
||||
readonly serviceAccountAutomount: false;
|
||||
readonly roleBindingName: string;
|
||||
readonly workspaceSize: string;
|
||||
readonly pipelineTimeout: string;
|
||||
readonly toolImage: string;
|
||||
readonly buildkitImage: string;
|
||||
};
|
||||
readonly source: {
|
||||
readonly repository: string;
|
||||
readonly worktreeRemote: string;
|
||||
readonly branch: string;
|
||||
readonly readUrl: string;
|
||||
readonly snapshotPrefix: string;
|
||||
};
|
||||
readonly build: Record<string, unknown> & {
|
||||
readonly dockerfile: string;
|
||||
readonly imageRepository: string;
|
||||
readonly networkMode: string;
|
||||
readonly proxy: Record<string, unknown>;
|
||||
};
|
||||
readonly gitops: Record<string, unknown> & {
|
||||
readonly readUrl: string;
|
||||
readonly writeUrl: string;
|
||||
readonly branch: string;
|
||||
readonly manifestPath: string;
|
||||
readonly releaseStatePath: string;
|
||||
readonly credentialSecretName: string;
|
||||
readonly credentialTokenKey: string;
|
||||
readonly credentialUsername: string;
|
||||
};
|
||||
readonly deployment: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SelfMediaCutoverTarget {
|
||||
readonly id: string;
|
||||
readonly mode: "host-process-to-kubernetes";
|
||||
readonly source: {
|
||||
readonly workspace: string;
|
||||
readonly publicAddress: string;
|
||||
readonly healthUrl: string;
|
||||
readonly stopCommand: readonly string[];
|
||||
readonly startCommand: readonly string[];
|
||||
readonly dataPaths: readonly string[];
|
||||
readonly excludes: readonly string[];
|
||||
};
|
||||
readonly destination: {
|
||||
readonly route: string;
|
||||
readonly namespace: string;
|
||||
readonly application: string;
|
||||
readonly publicAddress: string;
|
||||
readonly healthUrl: string;
|
||||
readonly dataClaim: string;
|
||||
readonly codexClaim: string;
|
||||
};
|
||||
readonly prepare: {
|
||||
readonly requiresFinalConfirmation: true;
|
||||
readonly phases: readonly string[];
|
||||
};
|
||||
readonly rollback: {
|
||||
readonly phases: readonly string[];
|
||||
readonly dataPolicy: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SelfMediaConfig {
|
||||
readonly defaultTargetId: string;
|
||||
readonly deliveryTargets: Readonly<Record<string, SelfMediaDeliveryTarget>>;
|
||||
readonly cutoverTargets: Readonly<Record<string, SelfMediaCutoverTarget>>;
|
||||
}
|
||||
|
||||
export function readSelfMediaConfig(): SelfMediaConfig {
|
||||
const root = record(Bun.YAML.parse(readFileSync(selfMediaConfigPath, "utf8")), selfMediaConfigRef);
|
||||
if (integer(root.version, "version") !== 1) throw new Error(`${selfMediaConfigRef}.version must be 1`);
|
||||
if (text(root.kind, "kind") !== "selfmedia-platform-delivery") throw new Error(`${selfMediaConfigRef}.kind must be selfmedia-platform-delivery`);
|
||||
const defaults = record(root.defaults, "defaults");
|
||||
const delivery = record(root.delivery, "delivery");
|
||||
const cutover = record(root.cutover, "cutover");
|
||||
const deliveryRecords = record(delivery.targets, "delivery.targets");
|
||||
const cutoverRecords = record(cutover.targets, "cutover.targets");
|
||||
const deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))]));
|
||||
const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))]));
|
||||
const defaultTargetId = text(defaults.targetId, "defaults.targetId");
|
||||
if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) {
|
||||
throw new Error(`${selfMediaConfigRef}.defaults.targetId must resolve in delivery.targets and cutover.targets`);
|
||||
}
|
||||
return { defaultTargetId, deliveryTargets, cutoverTargets };
|
||||
}
|
||||
|
||||
export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMediaDeliveryTarget {
|
||||
const config = readSelfMediaConfig();
|
||||
const id = targetId ?? config.defaultTargetId;
|
||||
const target = config.deliveryTargets[id];
|
||||
if (target === undefined) throw new Error(`unknown selfmedia delivery target ${id}; known targets: ${Object.keys(config.deliveryTargets).join(", ")}`);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget {
|
||||
const config = readSelfMediaConfig();
|
||||
const id = targetId ?? config.defaultTargetId;
|
||||
const target = config.cutoverTargets[id];
|
||||
if (target === undefined) throw new Error(`unknown selfmedia cutover target ${id}; known targets: ${Object.keys(config.cutoverTargets).join(", ")}`);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function selfMediaDeliveryConfigRef(targetId: string): string {
|
||||
return `${selfMediaConfigRef}#delivery.targets.${targetId}`;
|
||||
}
|
||||
|
||||
export function selfMediaEffectiveDeployment(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
...structuredClone(target.deployment),
|
||||
delivery: {
|
||||
enabled: true,
|
||||
source: {
|
||||
branch: target.source.branch,
|
||||
snapshotPrefix: target.source.snapshotPrefix,
|
||||
readUrl: target.source.readUrl,
|
||||
},
|
||||
build: structuredClone(target.build),
|
||||
gitops: structuredClone(target.gitops),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseDeliveryTarget(id: string, value: Record<string, unknown>): SelfMediaDeliveryTarget {
|
||||
const path = `delivery.targets.${id}`;
|
||||
const ci = record(value.ci, `${path}.ci`);
|
||||
const source = record(value.source, `${path}.source`);
|
||||
const build = record(value.build, `${path}.build`);
|
||||
const proxy = record(build.proxy, `${path}.build.proxy`);
|
||||
const gitops = record(value.gitops, `${path}.gitops`);
|
||||
const deployment = record(value.deployment, `${path}.deployment`);
|
||||
const target = record(deployment.target, `${path}.deployment.target`);
|
||||
const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`);
|
||||
const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`);
|
||||
if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`);
|
||||
if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`);
|
||||
if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`);
|
||||
if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`);
|
||||
if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`);
|
||||
if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`);
|
||||
if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`);
|
||||
if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`);
|
||||
const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`);
|
||||
if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`);
|
||||
return {
|
||||
id,
|
||||
node: id,
|
||||
lane: text(value.lane, `${path}.lane`),
|
||||
route: text(value.route, `${path}.route`),
|
||||
ci: {
|
||||
namespace: kubernetesName(ci.namespace, `${path}.ci.namespace`),
|
||||
pipeline: kubernetesName(ci.pipeline, `${path}.ci.pipeline`),
|
||||
pipelineRunPrefix: kubernetesName(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`),
|
||||
serviceAccountName: kubernetesName(ci.serviceAccountName, `${path}.ci.serviceAccountName`),
|
||||
serviceAccountAutomount: false,
|
||||
roleBindingName: kubernetesName(ci.roleBindingName, `${path}.ci.roleBindingName`),
|
||||
workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`),
|
||||
pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`),
|
||||
toolImage: text(ci.toolImage, `${path}.ci.toolImage`),
|
||||
buildkitImage: text(ci.buildkitImage, `${path}.ci.buildkitImage`),
|
||||
},
|
||||
source: {
|
||||
repository: "pikainc/selfmedia",
|
||||
worktreeRemote: url(source.worktreeRemote, `${path}.source.worktreeRemote`),
|
||||
branch: text(source.branch, `${path}.source.branch`),
|
||||
readUrl: url(source.readUrl, `${path}.source.readUrl`),
|
||||
snapshotPrefix: text(source.snapshotPrefix, `${path}.source.snapshotPrefix`),
|
||||
},
|
||||
build: {
|
||||
...structuredClone(build),
|
||||
dockerfile: relativePath(build.dockerfile, `${path}.build.dockerfile`),
|
||||
imageRepository: text(build.imageRepository, `${path}.build.imageRepository`),
|
||||
networkMode: text(build.networkMode, `${path}.build.networkMode`),
|
||||
proxy: structuredClone(proxy),
|
||||
},
|
||||
gitops: {
|
||||
...structuredClone(gitops),
|
||||
readUrl: url(gitops.readUrl, `${path}.gitops.readUrl`),
|
||||
writeUrl: url(gitops.writeUrl, `${path}.gitops.writeUrl`),
|
||||
branch: text(gitops.branch, `${path}.gitops.branch`),
|
||||
manifestPath: relativePath(gitops.manifestPath, `${path}.gitops.manifestPath`),
|
||||
releaseStatePath: relativePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`),
|
||||
credentialSecretName: kubernetesName(gitops.credentialSecretName, `${path}.gitops.credentialSecretName`),
|
||||
credentialTokenKey: text(gitops.credentialTokenKey, `${path}.gitops.credentialTokenKey`),
|
||||
credentialUsername: text(gitops.credentialUsername, `${path}.gitops.credentialUsername`),
|
||||
},
|
||||
deployment: structuredClone(deployment),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCutoverTarget(id: string, value: Record<string, unknown>): SelfMediaCutoverTarget {
|
||||
const path = `cutover.targets.${id}`;
|
||||
const source = record(value.source, `${path}.source`);
|
||||
const destination = record(value.destination, `${path}.destination`);
|
||||
const prepare = record(value.prepare, `${path}.prepare`);
|
||||
const rollback = record(value.rollback, `${path}.rollback`);
|
||||
const mode = text(value.mode, `${path}.mode`);
|
||||
if (mode !== "host-process-to-kubernetes") throw new Error(`${path}.mode must be host-process-to-kubernetes`);
|
||||
if (boolean(prepare.requiresFinalConfirmation, `${path}.prepare.requiresFinalConfirmation`) !== true) throw new Error(`${path}.prepare.requiresFinalConfirmation must be true`);
|
||||
return {
|
||||
id,
|
||||
mode,
|
||||
source: {
|
||||
workspace: absolutePath(source.workspace, `${path}.source.workspace`),
|
||||
publicAddress: url(source.publicAddress, `${path}.source.publicAddress`),
|
||||
healthUrl: url(source.healthUrl, `${path}.source.healthUrl`),
|
||||
stopCommand: stringArray(source.stopCommand, `${path}.source.stopCommand`),
|
||||
startCommand: stringArray(source.startCommand, `${path}.source.startCommand`),
|
||||
dataPaths: stringArray(source.dataPaths, `${path}.source.dataPaths`).map((item, index) => relativePath(item, `${path}.source.dataPaths[${index}]`)),
|
||||
excludes: stringArray(source.excludes, `${path}.source.excludes`).map((item, index) => relativePath(item, `${path}.source.excludes[${index}]`)),
|
||||
},
|
||||
destination: {
|
||||
route: text(destination.route, `${path}.destination.route`),
|
||||
namespace: kubernetesName(destination.namespace, `${path}.destination.namespace`),
|
||||
application: kubernetesName(destination.application, `${path}.destination.application`),
|
||||
publicAddress: url(destination.publicAddress, `${path}.destination.publicAddress`),
|
||||
healthUrl: url(destination.healthUrl, `${path}.destination.healthUrl`),
|
||||
dataClaim: kubernetesName(destination.dataClaim, `${path}.destination.dataClaim`),
|
||||
codexClaim: kubernetesName(destination.codexClaim, `${path}.destination.codexClaim`),
|
||||
},
|
||||
prepare: {
|
||||
requiresFinalConfirmation: true,
|
||||
phases: stringArray(prepare.phases, `${path}.prepare.phases`),
|
||||
},
|
||||
rollback: {
|
||||
phases: stringArray(rollback.phases, `${path}.rollback.phases`),
|
||||
dataPolicy: text(rollback.dataPolicy, `${path}.rollback.dataPolicy`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${selfMediaConfigRef}.${path} must be a non-empty single-line string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (!Number.isInteger(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an integer`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`${selfMediaConfigRef}.${path} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\n"))) throw new Error(`${selfMediaConfigRef}.${path} must be an array of non-empty single-line strings`);
|
||||
return [...value] as string[];
|
||||
}
|
||||
|
||||
function kubernetesName(value: unknown, path: string): string {
|
||||
const result = text(value, path);
|
||||
if (!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/u.test(result) || result.length > 63) throw new Error(`${selfMediaConfigRef}.${path} must be a Kubernetes name`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function absolutePath(value: unknown, path: string): string {
|
||||
const result = text(value, path);
|
||||
if (!result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be an absolute path without ..`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativePath(value: unknown, path: string): string {
|
||||
const result = text(value, path);
|
||||
if (result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be a relative path without ..`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function url(value: unknown, path: string): string {
|
||||
const result = text(value, path);
|
||||
const parsed = new URL(result);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${selfMediaConfigRef}.${path} must use http or https`);
|
||||
if (parsed.username.length > 0 || parsed.password.length > 0) throw new Error(`${selfMediaConfigRef}.${path} must not contain credentials`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
resolveSelfMediaDeliveryTarget,
|
||||
selfMediaDeliveryConfigRef,
|
||||
selfMediaEffectiveDeployment,
|
||||
type SelfMediaDeliveryTarget,
|
||||
} from "./selfmedia-config";
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
|
||||
export interface SelfMediaRendererBinding {
|
||||
readonly consumer: {
|
||||
readonly id: string;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly namespace: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly sourceArtifact: {
|
||||
readonly configRef: string;
|
||||
readonly mode: string;
|
||||
readonly renderer: string;
|
||||
};
|
||||
};
|
||||
readonly repository: {
|
||||
readonly id: string;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfMediaRenderedPipeline {
|
||||
readonly pipeline: Record<string, unknown>;
|
||||
readonly configRef: string;
|
||||
readonly effectiveConfigSha256: string;
|
||||
readonly sourceWorktreeRemote: string;
|
||||
}
|
||||
|
||||
export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline {
|
||||
const target = resolveSelfMediaDeliveryTarget(binding.consumer.node);
|
||||
const configRef = selfMediaDeliveryConfigRef(target.id);
|
||||
if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
|
||||
throw new Error("selfmedia-runtime requires embedded-pipeline-spec");
|
||||
}
|
||||
if (binding.consumer.sourceArtifact.configRef !== configRef) {
|
||||
throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}; observed ${binding.consumer.sourceArtifact.configRef}`);
|
||||
}
|
||||
assertBinding(target, binding);
|
||||
assertServiceRepositoryContract(sourceWorktree);
|
||||
const effectiveDeployment = selfMediaEffectiveDeployment(target);
|
||||
validateRuntimeRender(sourceWorktree, effectiveDeployment);
|
||||
const effectiveDeploymentB64 = Buffer.from(`${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8").toString("base64");
|
||||
return {
|
||||
pipeline: pipeline(target, effectiveDeploymentB64),
|
||||
configRef,
|
||||
effectiveConfigSha256: stableJsonSha256(target),
|
||||
sourceWorktreeRemote: target.source.worktreeRemote,
|
||||
};
|
||||
}
|
||||
|
||||
export function selfMediaSourceWorktreeRemote(targetId: string): string {
|
||||
return resolveSelfMediaDeliveryTarget(targetId).source.worktreeRemote;
|
||||
}
|
||||
|
||||
function pipeline(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record<string, unknown> {
|
||||
const params = [
|
||||
"revision",
|
||||
"source-snapshot-prefix",
|
||||
"git-read-url",
|
||||
"gitops-read-url",
|
||||
"gitops-write-url",
|
||||
"gitops-username",
|
||||
"gitops-secret-name",
|
||||
];
|
||||
const taskParams = params.map((name) => ({ name, type: "string" }));
|
||||
const taskParamBindings = params.map((name) => ({ name, value: `$(params.${name})` }));
|
||||
return {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "Pipeline",
|
||||
metadata: {
|
||||
name: target.ci.pipeline,
|
||||
namespace: target.ci.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/name": target.ci.pipeline,
|
||||
"app.kubernetes.io/part-of": "selfmedia-factory",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
params: taskParams,
|
||||
workspaces: [],
|
||||
tasks: [{
|
||||
name: "build-and-publish",
|
||||
params: taskParamBindings,
|
||||
taskSpec: {
|
||||
params: taskParams,
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: target.ci.workspaceSize } },
|
||||
{ name: "buildkit-state", emptyDir: { sizeLimit: "12Gi" } },
|
||||
{ name: "tmp", emptyDir: { sizeLimit: "4Gi" } },
|
||||
{
|
||||
name: "gitops-token",
|
||||
secret: {
|
||||
secretName: "$(params.gitops-secret-name)",
|
||||
defaultMode: 288,
|
||||
items: [{ key: target.gitops.credentialTokenKey, path: "token" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
sourceStep(target, effectiveDeploymentB64),
|
||||
prepareBuildkitStep(target),
|
||||
imageBuildStep(target),
|
||||
gitOpsPublishStep(target),
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sourceStep(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record<string, unknown> {
|
||||
const proxy = target.build.proxy;
|
||||
return {
|
||||
name: "immutable-source",
|
||||
image: target.ci.toolImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace",
|
||||
env: [
|
||||
{ name: "HTTP_PROXY", value: requiredString(proxy.http, "build.proxy.http") },
|
||||
{ name: "HTTPS_PROXY", value: requiredString(proxy.https, "build.proxy.https") },
|
||||
{ name: "ALL_PROXY", value: requiredString(proxy.all, "build.proxy.all") },
|
||||
{ name: "NO_PROXY", value: requiredStringArray(proxy.noProxy, "build.proxy.noProxy").join(",") },
|
||||
],
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
source_commit="$(params.revision)"
|
||||
snapshot_prefix="$(params.source-snapshot-prefix)"
|
||||
rm -rf /workspace/source /workspace/release
|
||||
askpass=/tmp/selfmedia-source-git-askpass
|
||||
cat >"$askpass" <<'ASKPASS'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;;
|
||||
*Password*) cat /var/run/selfmedia-gitops/token ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS
|
||||
chmod 700 "$askpass"
|
||||
export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)"
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
git clone --filter=blob:none --no-checkout "$(params.git-read-url)" /workspace/source
|
||||
cd /workspace/source
|
||||
git fetch --depth=1 --filter=blob:none origin "+$snapshot_prefix/$source_commit:refs/remotes/origin/selfmedia-source-snapshot"
|
||||
git checkout --detach "$source_commit"
|
||||
test "$(git rev-parse HEAD)" = "$source_commit"
|
||||
rm -f "$askpass"
|
||||
printf '%s' '${effectiveDeploymentB64}' | base64 -d > /workspace/effective-deployment.yaml
|
||||
bun install --frozen-lockfile
|
||||
bun deploy/scripts/prepare-build.ts \\
|
||||
--config /workspace/effective-deployment.yaml \\
|
||||
--source-root /workspace/source \\
|
||||
--source-commit "$source_commit" \\
|
||||
--source-snapshot-prefix "$snapshot_prefix" \\
|
||||
--output-dir /workspace/release
|
||||
`,
|
||||
securityContext: nonRootSecurity(),
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
{ name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function prepareBuildkitStep(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
name: "prepare-buildkit-state",
|
||||
image: target.ci.toolImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
script: "#!/bin/sh\nset -eu\nmkdir -p /home/user/.local/share/buildkit\nchown -R 1000:1000 /home/user/.local/share/buildkit\n",
|
||||
securityContext: { runAsUser: 0, runAsGroup: 0 },
|
||||
volumeMounts: [{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" }],
|
||||
};
|
||||
}
|
||||
|
||||
function imageBuildStep(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
name: "image-build",
|
||||
image: target.ci.buildkitImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace",
|
||||
env: [
|
||||
{ name: "SOURCE_COMMIT", value: "$(params.revision)" },
|
||||
{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" },
|
||||
],
|
||||
script: "#!/bin/sh\nset -eu\nexec /bin/sh /workspace/source/deploy/scripts/build-image.sh\n",
|
||||
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function gitOpsPublishStep(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
name: "gitops-publish",
|
||||
image: target.ci.toolImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace",
|
||||
env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }],
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
askpass=/tmp/selfmedia-git-askpass
|
||||
cat >"$askpass" <<'ASKPASS'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;;
|
||||
*Password*) cat /var/run/selfmedia-gitops/token ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS
|
||||
chmod 700 "$askpass"
|
||||
export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)"
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
bun /workspace/source/deploy/scripts/publish-gitops.ts \\
|
||||
--config /workspace/effective-deployment.yaml \\
|
||||
--source-root /workspace/source \\
|
||||
--metadata /workspace/build-metadata.json \\
|
||||
--source-commit "$SOURCE_COMMIT" \\
|
||||
--worktree /workspace/selfmedia-gitops
|
||||
rm -f "$askpass"
|
||||
`,
|
||||
securityContext: nonRootSecurity(),
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
{ name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function assertBinding(target: SelfMediaDeliveryTarget, binding: SelfMediaRendererBinding): void {
|
||||
const expected: Record<string, string> = {
|
||||
node: target.node,
|
||||
lane: target.lane,
|
||||
namespace: target.ci.namespace,
|
||||
pipeline: target.ci.pipeline,
|
||||
pipelineRunPrefix: target.ci.pipelineRunPrefix,
|
||||
};
|
||||
const observed: Record<string, string> = {
|
||||
node: binding.consumer.node,
|
||||
lane: binding.consumer.lane,
|
||||
namespace: binding.consumer.namespace,
|
||||
pipeline: binding.consumer.pipeline,
|
||||
pipelineRunPrefix: binding.consumer.pipelineRunPrefix,
|
||||
};
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (observed[key] !== value) throw new Error(`PaC ${binding.consumer.id} ${key}=${observed[key]} does not match selfmedia owner ${value}`);
|
||||
}
|
||||
const requiredParams: Readonly<Record<string, string>> = {
|
||||
node: target.node,
|
||||
source_branch: target.source.branch,
|
||||
source_snapshot_prefix: target.source.snapshotPrefix,
|
||||
git_read_url: target.source.readUrl,
|
||||
gitops_read_url: target.gitops.readUrl,
|
||||
gitops_write_url: target.gitops.writeUrl,
|
||||
gitops_branch: target.gitops.branch,
|
||||
gitops_username: target.gitops.credentialUsername,
|
||||
gitops_secret_name: target.gitops.credentialSecretName,
|
||||
pipeline_name: target.ci.pipeline,
|
||||
pipeline_run_prefix: target.ci.pipelineRunPrefix,
|
||||
service_account: target.ci.serviceAccountName,
|
||||
image_repository: target.build.imageRepository,
|
||||
pipeline_timeout: target.ci.pipelineTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(requiredParams)) {
|
||||
if (binding.repository.params[key] !== value) throw new Error(`PaC ${binding.consumer.id} repository params.${key} must match selfmedia owner`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertServiceRepositoryContract(sourceWorktree: string): void {
|
||||
const required = [
|
||||
"deploy/Dockerfile",
|
||||
"deploy/scripts/prepare-build.ts",
|
||||
"deploy/scripts/build-image.sh",
|
||||
"deploy/scripts/publish-gitops.ts",
|
||||
"deploy/scripts/render-runtime.ts",
|
||||
];
|
||||
for (const file of required) {
|
||||
if (!existsSync(resolve(sourceWorktree, file))) throw new Error(`selfmedia source repository is missing renderer contract file ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Record<string, unknown>): void {
|
||||
const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-selfmedia-render-"));
|
||||
const configPath = resolve(temporary, "effective-deployment.yaml");
|
||||
try {
|
||||
writeFileSync(configPath, `${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8");
|
||||
const result = spawnSync("bun", [
|
||||
"deploy/scripts/render-runtime.ts",
|
||||
"--config", configPath,
|
||||
"--image", `127.0.0.1:5000/selfmedia/newsroom@sha256:${"a".repeat(64)}`,
|
||||
"--source-commit", "b".repeat(40),
|
||||
], {
|
||||
cwd: sourceWorktree,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 60_000,
|
||||
});
|
||||
if (result.error !== undefined) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const evidence = `${result.stderr || result.stdout || "render-runtime failed"}`.trim().slice(-2000);
|
||||
throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`);
|
||||
}
|
||||
const output = result.stdout;
|
||||
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"];
|
||||
for (const marker of required) {
|
||||
if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`);
|
||||
}
|
||||
const forbidden = ["kind: Secret", "kind: Role\n", "kind: RoleBinding", "kind: ClusterRole", "kind: ClusterRoleBinding"];
|
||||
for (const marker of forbidden) {
|
||||
if (output.includes(marker)) throw new Error(`selfmedia service renderer output contains forbidden ${marker.trim()}`);
|
||||
}
|
||||
if ((output.match(/kind: PersistentVolumeClaim/gu) ?? []).length !== 2) throw new Error("selfmedia service renderer must produce exactly two PVCs");
|
||||
if ((output.match(/kind: NetworkPolicy/gu) ?? []).length < 3) throw new Error("selfmedia service renderer must produce at least three NetworkPolicies");
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function nonRootSecurity(): Record<string, unknown> {
|
||||
return {
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
runAsNonRoot: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ["ALL"] },
|
||||
};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredStringArray(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`);
|
||||
return value as string[];
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, existsSync, lstatSync, readdirSync } from "node:fs";
|
||||
import { relative, resolve, sep } from "node:path";
|
||||
|
||||
import { resolveSelfMediaCutoverTarget, resolveSelfMediaDeliveryTarget, selfMediaConfigRef, type SelfMediaCutoverTarget } from "./selfmedia-config";
|
||||
|
||||
type CutoverAction = "plan" | "prepare" | "status" | "rollback";
|
||||
|
||||
interface CutoverOptions {
|
||||
readonly targetId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
}
|
||||
|
||||
interface SourceDataSummary {
|
||||
readonly available: boolean;
|
||||
readonly files: number;
|
||||
readonly bytes: number;
|
||||
readonly digest: string | null;
|
||||
readonly digestBasis: "relative-path,size,sha256-content";
|
||||
readonly missingPaths: readonly string[];
|
||||
readonly excludedPaths: readonly string[];
|
||||
readonly valuesPrinted: false;
|
||||
}
|
||||
|
||||
export async function runSelfMediaCommand(args: string[]): Promise<Record<string, unknown>> {
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return help();
|
||||
if (args[0] !== "cutover") return { ok: false, error: "unsupported-selfmedia-command", args, help: help() };
|
||||
const action = args[1];
|
||||
if (action !== "plan" && action !== "prepare" && action !== "status" && action !== "rollback") {
|
||||
return { ok: false, error: "unsupported-selfmedia-cutover-command", args, help: help() };
|
||||
}
|
||||
const options = parseOptions(args.slice(2));
|
||||
const target = resolveSelfMediaCutoverTarget(options.targetId);
|
||||
const delivery = resolveSelfMediaDeliveryTarget(target.id);
|
||||
if (action === "plan") return cutoverPlan(target, delivery.route);
|
||||
if (action === "status") return await cutoverStatus(target, delivery.route);
|
||||
if (action === "prepare") return await cutoverPrepare(target, delivery.route, options);
|
||||
return cutoverRollback(target, delivery.route, options);
|
||||
}
|
||||
|
||||
function help(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "selfmedia cutover plan|prepare|status|rollback",
|
||||
configTruth: selfMediaConfigRef,
|
||||
usage: [
|
||||
"bun scripts/cli.ts selfmedia cutover plan --target NC01",
|
||||
"bun scripts/cli.ts selfmedia cutover prepare --target NC01 --dry-run",
|
||||
"bun scripts/cli.ts selfmedia cutover status --target NC01",
|
||||
"bun scripts/cli.ts selfmedia cutover rollback --target NC01 --dry-run",
|
||||
],
|
||||
boundary: "当前入口只做配置校验、源数据摘要和切换计划;不停止服务、不复制数据、不访问 k8s、不切换端口。",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): CutoverOptions {
|
||||
let targetId: string | null = null;
|
||||
let dryRun = false;
|
||||
let confirm = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const token = args[index];
|
||||
if (token === "--target") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
||||
targetId = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--confirm") {
|
||||
confirm = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--json") continue;
|
||||
throw new Error(`unsupported selfmedia cutover option ${token}`);
|
||||
}
|
||||
if (dryRun && confirm) throw new Error("--dry-run and --confirm are mutually exclusive");
|
||||
return { targetId, dryRun, confirm };
|
||||
}
|
||||
|
||||
function cutoverPlan(target: SelfMediaCutoverTarget, deliveryRoute: string): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
action: "selfmedia-cutover-plan",
|
||||
target: target.id,
|
||||
mode: target.mode,
|
||||
mutation: false,
|
||||
configTruth: `${selfMediaConfigRef}#cutover.targets.${target.id}`,
|
||||
routeAligned: target.destination.route === deliveryRoute,
|
||||
source: {
|
||||
workspace: target.source.workspace,
|
||||
healthUrl: target.source.healthUrl,
|
||||
dataPaths: target.source.dataPaths,
|
||||
excludes: target.source.excludes,
|
||||
},
|
||||
destination: target.destination,
|
||||
phases: target.prepare.phases,
|
||||
rollback: {
|
||||
phases: target.rollback.phases,
|
||||
dataPolicy: target.rollback.dataPolicy,
|
||||
},
|
||||
safety: {
|
||||
requiresFinalConfirmation: target.prepare.requiresFinalConfirmation,
|
||||
oldLifecycleStateExcluded: target.source.excludes.includes(".state/server.json"),
|
||||
runtimeExecutorAvailable: false,
|
||||
},
|
||||
next: `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run`,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function cutoverStatus(target: SelfMediaCutoverTarget, deliveryRoute: string): Promise<Record<string, unknown>> {
|
||||
const sourceData = await summarizeSourceData(target);
|
||||
return {
|
||||
ok: sourceData.available,
|
||||
action: "selfmedia-cutover-status",
|
||||
target: target.id,
|
||||
mutation: false,
|
||||
configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"),
|
||||
sourceWorkspacePresent: existsSync(target.source.workspace),
|
||||
sourceData,
|
||||
runtimeObservation: {
|
||||
status: "not-requested",
|
||||
reason: "read-only CLI does not contact the host service, k8s, Argo, or public endpoint",
|
||||
},
|
||||
next: sourceData.available
|
||||
? `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run`
|
||||
: "Restore or select the YAML-declared source workspace before preparing cutover.",
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function cutoverPrepare(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Promise<Record<string, unknown>> {
|
||||
const sourceData = await summarizeSourceData(target);
|
||||
const confirmedButUnsupported = options.confirm;
|
||||
return {
|
||||
ok: sourceData.available && !confirmedButUnsupported,
|
||||
action: "selfmedia-cutover-prepare",
|
||||
target: target.id,
|
||||
mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed",
|
||||
mutation: false,
|
||||
configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"),
|
||||
sourceData,
|
||||
proposedPhases: target.prepare.phases,
|
||||
portHandoff: {
|
||||
port: 4317,
|
||||
oldServiceStopCommand: target.source.stopCommand,
|
||||
destinationHealthUrl: target.destination.healthUrl,
|
||||
executed: false,
|
||||
},
|
||||
blocked: confirmedButUnsupported ? {
|
||||
code: "selfmedia-cutover-runtime-executor-not-implemented",
|
||||
reason: "本次交付只建立 YAML 所有权和只读/干运行入口,禁止从此命令执行真实切换。",
|
||||
} : null,
|
||||
next: confirmedButUnsupported
|
||||
? "由后续受控执行器实现 PVC seed、最终增量、端口释放、Argo 同步和原入口验收后再开放 --confirm。"
|
||||
: `bun scripts/cli.ts selfmedia cutover status --target ${target.id}`,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function cutoverRollback(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Record<string, unknown> {
|
||||
const confirmedButUnsupported = options.confirm;
|
||||
return {
|
||||
ok: !confirmedButUnsupported,
|
||||
action: "selfmedia-cutover-rollback",
|
||||
target: target.id,
|
||||
mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed",
|
||||
mutation: false,
|
||||
configReady: target.destination.route === deliveryRoute,
|
||||
proposedPhases: target.rollback.phases,
|
||||
dataPolicy: target.rollback.dataPolicy,
|
||||
blocked: confirmedButUnsupported ? {
|
||||
code: "selfmedia-cutover-runtime-executor-not-implemented",
|
||||
reason: "回滚命令当前只输出计划,不操作 Argo、Deployment、宿主进程或数据。",
|
||||
} : null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function summarizeSourceData(target: SelfMediaCutoverTarget): Promise<SourceDataSummary> {
|
||||
const root = resolve(target.source.workspace);
|
||||
const missingPaths: string[] = [];
|
||||
const files: { absolute: string; relative: string; size: number }[] = [];
|
||||
for (const declared of target.source.dataPaths) {
|
||||
const absolute = resolve(root, declared);
|
||||
if (!inside(root, absolute) || !existsSync(absolute)) {
|
||||
missingPaths.push(declared);
|
||||
continue;
|
||||
}
|
||||
collectFiles(root, absolute, target.source.excludes, files);
|
||||
}
|
||||
files.sort((left, right) => left.relative.localeCompare(right.relative));
|
||||
const digest = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for (const file of files) {
|
||||
const contentDigest = await hashFile(file.absolute);
|
||||
bytes += file.size;
|
||||
digest.update(file.relative);
|
||||
digest.update("\0");
|
||||
digest.update(String(file.size));
|
||||
digest.update("\0");
|
||||
digest.update(contentDigest);
|
||||
digest.update("\n");
|
||||
}
|
||||
return {
|
||||
available: missingPaths.length === 0,
|
||||
files: files.length,
|
||||
bytes,
|
||||
digest: missingPaths.length === 0 ? `sha256:${digest.digest("hex")}` : null,
|
||||
digestBasis: "relative-path,size,sha256-content",
|
||||
missingPaths,
|
||||
excludedPaths: target.source.excludes,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function collectFiles(root: string, current: string, excludes: readonly string[], output: { absolute: string; relative: string; size: number }[]): void {
|
||||
const currentRelative = relative(root, current).split(sep).join("/");
|
||||
if (isExcluded(currentRelative, excludes)) return;
|
||||
const stat = lstatSync(current);
|
||||
if (stat.isSymbolicLink()) return;
|
||||
if (stat.isFile()) {
|
||||
output.push({ absolute: current, relative: currentRelative, size: stat.size });
|
||||
return;
|
||||
}
|
||||
if (!stat.isDirectory()) return;
|
||||
for (const entry of readdirSync(current).sort()) collectFiles(root, resolve(current, entry), excludes, output);
|
||||
}
|
||||
|
||||
function isExcluded(path: string, excludes: readonly string[]): boolean {
|
||||
return excludes.some((excluded) => path === excluded || path.startsWith(`${excluded}/`));
|
||||
}
|
||||
|
||||
function inside(root: string, candidate: string): boolean {
|
||||
return candidate === root || candidate.startsWith(`${root}${sep}`);
|
||||
}
|
||||
|
||||
async function hashFile(path: string): Promise<string> {
|
||||
const digest = createHash("sha256");
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const stream = createReadStream(path);
|
||||
stream.on("data", (chunk) => digest.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", resolvePromise);
|
||||
});
|
||||
return digest.digest("hex");
|
||||
}
|
||||
Reference in New Issue
Block a user