Merge remote-tracking branch 'origin/master' into fix/web-probe-memory-guard
This commit is contained in:
@@ -444,6 +444,15 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "selfmedia") {
|
||||
const { runSelfMediaCommand } = await import("./src/selfmedia");
|
||||
const result = await runSelfMediaCommand(args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "health") {
|
||||
const result = runHealthCommand(args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
|
||||
@@ -87,6 +87,10 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
|
||||
.split(/^---\s*$/mu)
|
||||
.map((item) => Bun.YAML.parse(item) as any);
|
||||
expect(documents.map((item) => item.kind)).toEqual([
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
|
||||
@@ -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(8);
|
||||
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(8);
|
||||
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,12 +78,21 @@ test("owning YAML renders one child Application and the complete durable bridge
|
||||
"Deployment",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
"ValidatingAdmissionPolicyBinding",
|
||||
"ServiceAccount",
|
||||
"ConfigMap",
|
||||
"Deployment",
|
||||
]);
|
||||
const pacReadOnlyNamespaces = documents
|
||||
.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", "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),
|
||||
};
|
||||
|
||||
@@ -25,9 +25,18 @@ function documents(value: string): any[] {
|
||||
|
||||
test("admission contract rejects malformed required declarations instead of silently downgrading", () => {
|
||||
const source = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any;
|
||||
const consumer = source.consumers.find((item: any) => item.deliveryProvenance !== undefined);
|
||||
consumer.deliveryProvenance.required = "true";
|
||||
expect(() => parsePacAdmissionContract(source)).toThrow("deliveryProvenance.required must be boolean true or false");
|
||||
const malformedRequired = structuredClone(source);
|
||||
malformedRequired.consumers.find((item: any) => item.deliveryProvenance !== undefined).deliveryProvenance.required = "true";
|
||||
expect(() => parsePacAdmissionContract(malformedRequired)).toThrow("deliveryProvenance.required must be boolean true or false");
|
||||
|
||||
const sharedDefault = structuredClone(source);
|
||||
sharedDefault.consumers.find((item: any) => item.deliveryProvenance !== undefined).deliveryProvenance.executionServiceAccountName = "default";
|
||||
expect(() => parsePacAdmissionContract(sharedDefault)).toThrow("must not reserve the shared default ServiceAccount");
|
||||
|
||||
const duplicatedServiceAccount = structuredClone(source);
|
||||
const duplicatedRequiredConsumers = duplicatedServiceAccount.consumers.filter((item: any) => item.deliveryProvenance !== undefined);
|
||||
duplicatedRequiredConsumers[1].deliveryProvenance.executionServiceAccountName = duplicatedRequiredConsumers[0].deliveryProvenance.executionServiceAccountName;
|
||||
expect(() => parsePacAdmissionContract(duplicatedServiceAccount)).toThrow("executionServiceAccountName must be unique per target");
|
||||
});
|
||||
|
||||
test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => {
|
||||
@@ -59,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"]);
|
||||
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];
|
||||
@@ -79,6 +88,9 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
expect(expressions).toContain("object.metadata.name.startsWith");
|
||||
expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]');
|
||||
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");
|
||||
@@ -113,20 +125,28 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
expect(queueGuard.expression.match(/admission-pac-v2:agentrun-nc01-v02/gu)).toHaveLength(2);
|
||||
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 consumer default RBAC has one automatic desired owner 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"]);
|
||||
const role = rbac[0];
|
||||
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);
|
||||
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 desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind);
|
||||
expect(desiredKinds).toEqual([
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
@@ -348,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", () => {
|
||||
|
||||
@@ -106,6 +106,11 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
|
||||
});
|
||||
const markerValues = new Set(consumers.map((consumer) => consumer.markerValue));
|
||||
if (markerValues.size !== consumers.length) throw new Error("deliveryProvenance markerValue must be unique per consumer");
|
||||
for (const consumer of consumers) {
|
||||
if (consumer.executionServiceAccountName === "default") throw new Error(`deliveryProvenance executionServiceAccountName for ${consumer.id} must not reserve the shared default ServiceAccount`);
|
||||
}
|
||||
const serviceAccountKeys = consumers.map((consumer) => `${consumer.node.toLowerCase()}\u0000${consumer.executionServiceAccountName}`);
|
||||
if (new Set(serviceAccountKeys).size !== serviceAccountKeys.length) throw new Error("deliveryProvenance executionServiceAccountName must be unique per target");
|
||||
const version = required(provenance.version, "deliveryProvenance.version");
|
||||
const versionMatch = version.match(/-v([1-9][0-9]*)$/u);
|
||||
if (versionMatch === null) throw new Error("deliveryProvenance.version must end with a positive -vN resource epoch");
|
||||
|
||||
@@ -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
|
||||
@@ -1287,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"
|
||||
@@ -1316,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,
|
||||
@@ -1329,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", () => {
|
||||
|
||||
@@ -17,9 +17,11 @@ import type { RenderedCliResult } from "./output";
|
||||
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";
|
||||
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";
|
||||
|
||||
@@ -48,6 +50,16 @@ export interface PacSourceArtifactBinding {
|
||||
readonly namespace: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly argoNamespace: string;
|
||||
readonly argoApplication: string;
|
||||
readonly argoBootstrap: {
|
||||
readonly project: string;
|
||||
readonly repoUrl: string;
|
||||
readonly targetRevision: string;
|
||||
readonly path: string;
|
||||
readonly destinationNamespace: string;
|
||||
readonly automated: boolean;
|
||||
} | null;
|
||||
readonly deliveryProvenance?: {
|
||||
readonly markerAnnotation: string;
|
||||
readonly markerValue: string;
|
||||
@@ -441,7 +453,11 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree
|
||||
const sourceArtifact = binding.consumer.sourceArtifact;
|
||||
const rendered = sourceArtifact.renderer === "agentrun-control-plane"
|
||||
? renderAgentRunDesiredPipeline(binding)
|
||||
: renderHwlabDesiredPipeline(binding, sourceWorktree);
|
||||
: sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? renderHwlabDesiredPipeline(binding, sourceWorktree)
|
||||
: sourceArtifact.renderer === "sub2rank-platform-service"
|
||||
? renderSub2RankDesiredPipeline(binding)
|
||||
: renderSelfMediaPipeline(binding, sourceWorktree);
|
||||
const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? rendered.pipeline
|
||||
: withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance);
|
||||
@@ -512,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");
|
||||
@@ -581,9 +610,17 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec
|
||||
name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`,
|
||||
namespace: binding.consumer.namespace,
|
||||
annotations: pipelineRunAnnotations(binding, provenance),
|
||||
labels: pipelineRunLabels(binding, "agentrun"),
|
||||
labels: pipelineRunLabels(
|
||||
binding,
|
||||
provenance.renderer === "sub2rank-platform-service"
|
||||
? "sub2rank"
|
||||
: provenance.renderer === "selfmedia-runtime"
|
||||
? "selfmedia"
|
||||
: "agentrun",
|
||||
),
|
||||
},
|
||||
spec: {
|
||||
...(binding.repository.params.pipeline_timeout === undefined ? {} : { timeouts: { pipeline: binding.repository.params.pipeline_timeout } }),
|
||||
pipelineSpec: desiredSpec,
|
||||
taskRunTemplate: taskRunTemplate(binding),
|
||||
params: pipelineRunParams(binding, desiredSpec),
|
||||
@@ -647,23 +684,42 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab"): 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",
|
||||
}
|
||||
: {
|
||||
};
|
||||
}
|
||||
if (partOf === "selfmedia") {
|
||||
return {
|
||||
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
|
||||
"app.kubernetes.io/part-of": "selfmedia-factory",
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"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> {
|
||||
@@ -808,6 +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 === "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;
|
||||
}
|
||||
@@ -923,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") 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,6 +154,10 @@ interface PacConsumer {
|
||||
path: string;
|
||||
destinationNamespace: string;
|
||||
automated: boolean;
|
||||
repositoryCredential: {
|
||||
secretName: string;
|
||||
username: string;
|
||||
} | null;
|
||||
} | null;
|
||||
deliveryProvenance: {
|
||||
required: true;
|
||||
@@ -165,6 +169,11 @@ interface PacConsumer {
|
||||
closeoutGitOpsMirrorFlush: boolean;
|
||||
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
|
||||
sourceArtifact: PacSourceArtifactSpec | null;
|
||||
runnerServiceAccount: {
|
||||
name: string;
|
||||
automountServiceAccountToken: false;
|
||||
roleBindingName: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface CommonOptions {
|
||||
@@ -271,6 +280,9 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
|
||||
namespace: consumer.namespace,
|
||||
pipeline: consumer.pipeline,
|
||||
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
||||
argoNamespace: consumer.argoNamespace,
|
||||
argoApplication: consumer.argoApplication,
|
||||
argoBootstrap: consumer.argoBootstrap,
|
||||
deliveryProvenance: consumer.deliveryProvenance === null ? null : {
|
||||
markerAnnotation: pac.deliveryProvenance.markerAnnotation,
|
||||
markerValue: consumer.deliveryProvenance.markerValue,
|
||||
@@ -495,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),
|
||||
@@ -511,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;
|
||||
@@ -537,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"] 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);
|
||||
@@ -545,6 +575,8 @@ function parseSourceArtifact(value: Record<string, unknown>, path: string): PacS
|
||||
if (mode === "remote-pipeline-annotation" && pipelinePath === null) throw new Error(`${path}.pipelinePath is required for remote-pipeline-annotation`);
|
||||
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,
|
||||
@@ -755,6 +787,7 @@ function validateConfig(config: PacConfig): void {
|
||||
}
|
||||
const consumerIds = new Set<string>();
|
||||
const markerValues = new Set<string>();
|
||||
const reservedServiceAccounts = new Set<string>();
|
||||
for (const consumer of config.consumers) {
|
||||
if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`);
|
||||
consumerIds.add(consumer.id);
|
||||
@@ -764,6 +797,14 @@ function validateConfig(config: PacConfig): void {
|
||||
if (consumer.sourceArtifact === null) throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance requires sourceArtifact ownership`);
|
||||
if (markerValues.has(consumer.deliveryProvenance.markerValue)) throw new Error(`${configLabel}.consumers deliveryProvenance.markerValue must be unique: ${consumer.deliveryProvenance.markerValue}`);
|
||||
markerValues.add(consumer.deliveryProvenance.markerValue);
|
||||
if (consumer.deliveryProvenance.executionServiceAccountName === "default") {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must not reserve the shared default ServiceAccount`);
|
||||
}
|
||||
const serviceAccountKey = `${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`;
|
||||
if (reservedServiceAccounts.has(serviceAccountKey)) {
|
||||
throw new Error(`${configLabel}.consumers deliveryProvenance.executionServiceAccountName must be unique per target: ${consumer.deliveryProvenance.executionServiceAccountName}`);
|
||||
}
|
||||
reservedServiceAccounts.add(serviceAccountKey);
|
||||
if (repository.params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match repository params.service_account`);
|
||||
}
|
||||
@@ -772,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`);
|
||||
@@ -1227,6 +1284,9 @@ 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_ADMISSION_POLICY_NAME: admissionIdentity.policyName,
|
||||
UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName,
|
||||
@@ -1242,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),
|
||||
};
|
||||
@@ -1288,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 "";
|
||||
@@ -1411,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);
|
||||
@@ -1427,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,
|
||||
@@ -1444,6 +1535,7 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
|
||||
runtime,
|
||||
diagnostics,
|
||||
admissionProvenance,
|
||||
consumerBootstrap,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -1615,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,
|
||||
};
|
||||
}
|
||||
@@ -1666,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,
|
||||
@@ -1729,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,
|
||||
@@ -1950,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);
|
||||
@@ -1970,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)]))),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { rootPath } from "./config";
|
||||
import type { PublicServiceExposure, PublicServiceTarget } from "./platform-infra-public-service";
|
||||
import { assertNoDuplicateYamlMappingKeys, createYamlFieldReader, readYamlRecord, resolveRepoPath } from "./platform-infra-ops-library";
|
||||
@@ -16,20 +16,61 @@ export interface Sub2RankConfig {
|
||||
metadata: { id: string; owner: string; relatedIssues: number[] };
|
||||
defaults: { targetId: string };
|
||||
application: {
|
||||
repository: string;
|
||||
remote: string;
|
||||
branch: string;
|
||||
sourceRoot: string;
|
||||
configRef: string;
|
||||
sourceConfigPath: string;
|
||||
dockerfile: string;
|
||||
runtimeTarget: string;
|
||||
configText: string;
|
||||
configSha256: string;
|
||||
sourceCommit: string;
|
||||
sourceClean: boolean;
|
||||
sourceRemotePresent: boolean;
|
||||
sourceRemoteMatches: boolean;
|
||||
configMatchesSourceCommit: boolean;
|
||||
deploymentBlockPresent: boolean;
|
||||
automaticCreditEnabled: boolean;
|
||||
server: { listenPort: number; databasePath: string; envKeys: string[] };
|
||||
};
|
||||
image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never" };
|
||||
image: { repository: string; pullPolicy: "Always" | "IfNotPresent" | "Never" };
|
||||
delivery: {
|
||||
enabled: boolean;
|
||||
pipeline: {
|
||||
namespace: string;
|
||||
name: string;
|
||||
runPrefix: string;
|
||||
serviceAccountName: string;
|
||||
timeout: string;
|
||||
workspaceSize: string;
|
||||
toolsImage: string;
|
||||
buildkitImage: string;
|
||||
sourceSnapshotPrefix: string;
|
||||
};
|
||||
build: {
|
||||
networkMode: "host";
|
||||
proxy: { http: string; https: string; all: string; noProxy: string[] };
|
||||
};
|
||||
gitops: {
|
||||
readUrl: string;
|
||||
writeUrl: string;
|
||||
branch: string;
|
||||
manifestPath: string;
|
||||
releaseStatePath: string;
|
||||
maxPushAttempts: number;
|
||||
author: { name: string; email: string };
|
||||
application: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
project: string;
|
||||
path: string;
|
||||
destinationNamespace: string;
|
||||
automated: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
targets: Sub2RankTarget[];
|
||||
runtime: {
|
||||
sharedNetworkPolicyRef: { name: string; managedBySub2Rank: false };
|
||||
@@ -82,15 +123,17 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
const defaultsRecord = y.objectField(root, "defaults", "");
|
||||
const applicationRecord = y.objectField(root, "application", "");
|
||||
const imageRecord = y.objectField(root, "image", "");
|
||||
const deliveryRecord = y.objectField(root, "delivery", "");
|
||||
const runtimeRecord = y.objectField(root, "runtime", "");
|
||||
const defaults = { targetId: simpleId(y.stringField(defaultsRecord, "targetId", "defaults"), "defaults.targetId") };
|
||||
const application = parseApplication(applicationRecord);
|
||||
const image = parseImage(imageRecord);
|
||||
const delivery = parseDelivery(deliveryRecord);
|
||||
const runtimeBase = parseRuntime(runtimeRecord);
|
||||
const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId);
|
||||
const targets = parseTargets(root.targets, secret);
|
||||
if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`);
|
||||
validateResolvedConfig(application, image, runtimeBase, secret, targets);
|
||||
validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets);
|
||||
return {
|
||||
version,
|
||||
kind: "platform-infra-sub2rank",
|
||||
@@ -102,6 +145,7 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
defaults,
|
||||
application,
|
||||
image,
|
||||
delivery,
|
||||
targets,
|
||||
runtime: { ...runtimeBase, secret: { ...runtimeBase.secret, ...secret } },
|
||||
};
|
||||
@@ -116,10 +160,16 @@ export function resolveSub2RankTarget(config: Sub2RankConfig, targetId: string |
|
||||
}
|
||||
|
||||
function parseApplication(record: Record<string, unknown>): Sub2RankConfig["application"] {
|
||||
const repository = repositoryValue(y.stringField(record, "repository", "application"), "application.repository");
|
||||
const remote = y.stringField(record, "remote", "application");
|
||||
const branch = simpleId(y.stringField(record, "branch", "application"), "application.branch");
|
||||
const sourceRoot = y.absolutePathField(record, "sourceRoot", "application");
|
||||
const configRef = y.absolutePathField(record, "configRef", "application");
|
||||
const sourceConfigPath = safeRelativePath(y.stringField(record, "sourceConfigPath", "application"), "application.sourceConfigPath");
|
||||
const dockerfile = safeRelativePath(y.stringField(record, "dockerfile", "application"), "application.dockerfile");
|
||||
const runtimeTarget = simpleId(y.stringField(record, "runtimeTarget", "application"), "application.runtimeTarget");
|
||||
if (!configRef.startsWith(`${sourceRoot.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.application.configRef must be inside application.sourceRoot`);
|
||||
if (!existsSync(resolve(sourceRoot, dockerfile))) throw new Error(`${sub2RankConfigLabel}.application.dockerfile does not exist under application.sourceRoot`);
|
||||
const configText = readFileSync(configRef, "utf8");
|
||||
assertNoDuplicateYamlMappingKeys(configText, configRef);
|
||||
const app = y.asRecord(Bun.YAML.parse(configText), configRef);
|
||||
@@ -140,18 +190,25 @@ function parseApplication(record: Record<string, unknown>): Sub2RankConfig["appl
|
||||
const sourceCommit = gitText(sourceRoot, ["rev-parse", "HEAD"], "resolve Sub2Rank source commit");
|
||||
if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error(`${sourceRoot} HEAD must resolve to a full Git commit`);
|
||||
const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true);
|
||||
const remote = gitText(sourceRoot, ["remote"], "read Sub2Rank source remotes", true);
|
||||
const observedRemote = gitText(sourceRoot, ["remote", "get-url", "origin"], "read Sub2Rank origin", true);
|
||||
const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/");
|
||||
if (configRelativePath !== sourceConfigPath) throw new Error(`${sub2RankConfigLabel}.application.sourceConfigPath must resolve to application.configRef`);
|
||||
const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false);
|
||||
return {
|
||||
repository,
|
||||
remote,
|
||||
branch,
|
||||
sourceRoot,
|
||||
configRef,
|
||||
sourceConfigPath,
|
||||
dockerfile,
|
||||
runtimeTarget,
|
||||
configText,
|
||||
configSha256: createHash("sha256").update(configText).digest("hex"),
|
||||
sourceCommit,
|
||||
sourceClean: statusShort.length === 0,
|
||||
sourceRemotePresent: remote.split(/\r?\n/u).some((value) => value.trim().length > 0),
|
||||
sourceRemotePresent: observedRemote.length > 0,
|
||||
sourceRemoteMatches: sameRepositoryIdentity(remote, observedRemote),
|
||||
configMatchesSourceCommit: committedConfig === configText.trimEnd(),
|
||||
deploymentBlockPresent: app.deployment !== undefined,
|
||||
automaticCreditEnabled,
|
||||
@@ -165,11 +222,66 @@ function parseApplication(record: Record<string, unknown>): Sub2RankConfig["appl
|
||||
|
||||
function parseImage(record: Record<string, unknown>): Sub2RankConfig["image"] {
|
||||
const repository = y.stringField(record, "repository", "image");
|
||||
const tag = y.stringField(record, "tag", "image");
|
||||
const pullPolicy = y.enumField(record, "pullPolicy", "image", ["Always", "IfNotPresent", "Never"] as const);
|
||||
if (!/^[A-Za-z0-9._:/-]+$/u.test(repository) || repository.endsWith("/")) throw new Error(`${sub2RankConfigLabel}.image.repository has an unsupported format`);
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${sub2RankConfigLabel}.image.tag has an unsupported format`);
|
||||
return { repository, tag, pullPolicy };
|
||||
return { repository, pullPolicy };
|
||||
}
|
||||
|
||||
function parseDelivery(record: Record<string, unknown>): Sub2RankConfig["delivery"] {
|
||||
const pipeline = y.objectField(record, "pipeline", "delivery");
|
||||
const build = y.objectField(record, "build", "delivery");
|
||||
const proxy = y.objectField(build, "proxy", "delivery.build");
|
||||
const gitops = y.objectField(record, "gitops", "delivery");
|
||||
const author = y.objectField(gitops, "author", "delivery.gitops");
|
||||
const application = y.objectField(gitops, "application", "delivery.gitops");
|
||||
const timeout = y.stringField(pipeline, "timeout", "delivery.pipeline");
|
||||
if (!/^[1-9][0-9]*s$/u.test(timeout)) throw new Error(`${sub2RankConfigLabel}.delivery.pipeline.timeout must be positive seconds`);
|
||||
const sourceSnapshotPrefix = y.stringField(pipeline, "sourceSnapshotPrefix", "delivery.pipeline");
|
||||
if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(sourceSnapshotPrefix) || sourceSnapshotPrefix.includes("..")) throw new Error(`${sub2RankConfigLabel}.delivery.pipeline.sourceSnapshotPrefix must be a safe refs/ prefix`);
|
||||
const email = y.stringField(author, "email", "delivery.gitops.author");
|
||||
if (!/^[^@\s]+@[^@\s]+$/u.test(email)) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.author.email must be an email address`);
|
||||
const automated = y.booleanField(application, "automated", "delivery.gitops.application");
|
||||
if (!automated) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.automated must remain true`);
|
||||
return {
|
||||
enabled: y.booleanField(record, "enabled", "delivery"),
|
||||
pipeline: {
|
||||
namespace: y.kubernetesNameField(pipeline, "namespace", "delivery.pipeline"),
|
||||
name: y.kubernetesNameField(pipeline, "name", "delivery.pipeline"),
|
||||
runPrefix: y.kubernetesNameField(pipeline, "runPrefix", "delivery.pipeline"),
|
||||
serviceAccountName: y.kubernetesNameField(pipeline, "serviceAccountName", "delivery.pipeline"),
|
||||
timeout,
|
||||
workspaceSize: storageSize(y.stringField(pipeline, "workspaceSize", "delivery.pipeline")),
|
||||
toolsImage: imageValue(y.stringField(pipeline, "toolsImage", "delivery.pipeline"), "delivery.pipeline.toolsImage"),
|
||||
buildkitImage: imageValue(y.stringField(pipeline, "buildkitImage", "delivery.pipeline"), "delivery.pipeline.buildkitImage"),
|
||||
sourceSnapshotPrefix,
|
||||
},
|
||||
build: {
|
||||
networkMode: y.enumField(build, "networkMode", "delivery.build", ["host"] as const),
|
||||
proxy: {
|
||||
http: httpUrlValue(y.stringField(proxy, "http", "delivery.build.proxy"), "delivery.build.proxy.http"),
|
||||
https: httpUrlValue(y.stringField(proxy, "https", "delivery.build.proxy"), "delivery.build.proxy.https"),
|
||||
all: httpUrlValue(y.stringField(proxy, "all", "delivery.build.proxy"), "delivery.build.proxy.all"),
|
||||
noProxy: y.stringArrayField(proxy, "noProxy", "delivery.build.proxy"),
|
||||
},
|
||||
},
|
||||
gitops: {
|
||||
readUrl: httpUrlValue(y.stringField(gitops, "readUrl", "delivery.gitops"), "delivery.gitops.readUrl"),
|
||||
writeUrl: httpUrlValue(y.stringField(gitops, "writeUrl", "delivery.gitops"), "delivery.gitops.writeUrl"),
|
||||
branch: simpleId(y.stringField(gitops, "branch", "delivery.gitops"), "delivery.gitops.branch"),
|
||||
manifestPath: safeRelativePath(y.stringField(gitops, "manifestPath", "delivery.gitops"), "delivery.gitops.manifestPath"),
|
||||
releaseStatePath: safeRelativePath(y.stringField(gitops, "releaseStatePath", "delivery.gitops"), "delivery.gitops.releaseStatePath"),
|
||||
maxPushAttempts: positiveInteger(gitops, "maxPushAttempts", "delivery.gitops"),
|
||||
author: { name: y.stringField(author, "name", "delivery.gitops.author"), email },
|
||||
application: {
|
||||
name: y.kubernetesNameField(application, "name", "delivery.gitops.application"),
|
||||
namespace: y.kubernetesNameField(application, "namespace", "delivery.gitops.application"),
|
||||
project: y.kubernetesNameField(application, "project", "delivery.gitops.application"),
|
||||
path: safeRelativePath(y.stringField(application, "path", "delivery.gitops.application"), "delivery.gitops.application.path"),
|
||||
destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", "delivery.gitops.application"),
|
||||
automated: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntime(record: Record<string, unknown>): Omit<Sub2RankConfig["runtime"], "secret"> & {
|
||||
@@ -328,13 +440,19 @@ function resolveSecretDeclaration(configRef: string, declarationId: string): Res
|
||||
function validateResolvedConfig(
|
||||
application: Sub2RankConfig["application"],
|
||||
image: Sub2RankConfig["image"],
|
||||
delivery: Sub2RankConfig["delivery"],
|
||||
runtime: ReturnType<typeof parseRuntime>,
|
||||
secret: ResolvedSecretDeclaration,
|
||||
targets: Sub2RankTarget[],
|
||||
): void {
|
||||
if (!/^[a-f0-9]{40}$/u.test(image.tag) || image.tag !== application.sourceCommit) throw new Error(`${sub2RankConfigLabel}.image.tag must equal the full application source commit ${application.sourceCommit}`);
|
||||
if (!application.sourceClean) throw new Error(`${sub2RankConfigLabel}.application.sourceRoot must be clean before rendering deployment config`);
|
||||
if (!application.configMatchesSourceCommit) throw new Error(`${sub2RankConfigLabel}.application.configRef must match the file stored at application source commit ${application.sourceCommit}`);
|
||||
if (!application.sourceRemoteMatches) throw new Error(`${sub2RankConfigLabel}.application.remote must identify the application.sourceRoot origin repository`);
|
||||
if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) throw new Error(`${sub2RankConfigLabel}.application.repository must match application.remote`);
|
||||
if (!delivery.enabled) throw new Error(`${sub2RankConfigLabel}.delivery.enabled must remain true while Sub2Rank is deployed`);
|
||||
if (delivery.gitops.maxPushAttempts > 5) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.maxPushAttempts must be <= 5`);
|
||||
if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.manifestPath must be directly under delivery.gitops.application.path`);
|
||||
if (!delivery.build.proxy.noProxy.includes("hyueapi.com") || !delivery.build.proxy.noProxy.includes(".hyueapi.com")) throw new Error(`${sub2RankConfigLabel}.delivery.build.proxy.noProxy must retain hyueapi.com and .hyueapi.com`);
|
||||
if (!image.repository.startsWith("127.0.0.1:5000/")) throw new Error(`${sub2RankConfigLabel}.image.repository must use the NC01 local registry`);
|
||||
const required = new Set(runtime.secret.requiredTargetKeys);
|
||||
const provided = new Set(secret.mappings.map((item) => item.targetKey));
|
||||
const missing = [...required].filter((key) => !provided.has(key));
|
||||
@@ -345,6 +463,7 @@ function validateResolvedConfig(
|
||||
for (const target of targets) {
|
||||
if (target.route !== secret.route || target.namespace !== secret.namespace) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}] route/namespace must match runtime Secret target ${secret.targetId}`);
|
||||
if (target.publicExposure.frpc.localPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}].publicExposure.frpc.localPort must match runtime.service.port`);
|
||||
if (target.namespace !== delivery.gitops.application.destinationNamespace) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.destinationNamespace must match the runtime target namespace`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +522,50 @@ function sourceRefValue(value: unknown, path: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeRelativePath(value: string, path: string): string {
|
||||
if (value.startsWith("/") || value.split(/[\\/]/u).includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be a safe relative path`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function repositoryValue(value: string, path: string): string {
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be owner/repository`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function httpUrlValue(value: string, path: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${sub2RankConfigLabel}.${path} must be an HTTP URL`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${sub2RankConfigLabel}.${path} must be an HTTP URL`);
|
||||
if (parsed.username.length > 0 || parsed.password.length > 0 || parsed.hash.length > 0) throw new Error(`${sub2RankConfigLabel}.${path} must not contain credentials or a fragment`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameRepositoryIdentity(left: string, right: string): boolean {
|
||||
const identity = (value: string): string | null => {
|
||||
const scp = /^[^/@\s]+@([^:\s]+):(.+)$/u.exec(value.trim());
|
||||
let host = scp?.[1] ?? "";
|
||||
let path = scp?.[2] ?? "";
|
||||
if (host.length === 0 || path.length === 0) {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
host = parsed.hostname;
|
||||
path = parsed.pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const parts = path.replace(/^\/+|\/+$/gu, "").split("/");
|
||||
if (parts.length !== 2) return null;
|
||||
return `${host.toLowerCase()}/${parts[0]?.toLowerCase()}/${parts[1]?.replace(/\.git$/iu, "").toLowerCase()}`;
|
||||
};
|
||||
const leftIdentity = identity(left);
|
||||
return leftIdentity !== null && leftIdentity === identity(right);
|
||||
}
|
||||
|
||||
function kubernetesNameValue(value: unknown, path: string): string {
|
||||
const text = stringValue(value, path);
|
||||
if (!/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(text)) throw new Error(`${path} must be a Kubernetes name`);
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
import {
|
||||
readSub2RankConfig,
|
||||
resolveSub2RankTarget,
|
||||
sub2RankConfigLabel,
|
||||
type Sub2RankConfig,
|
||||
type Sub2RankTarget,
|
||||
} from "./platform-infra-sub2rank-config";
|
||||
import {
|
||||
renderSub2RankManifest,
|
||||
sub2RankConfigBase64Placeholder,
|
||||
sub2RankConfigSha256Placeholder,
|
||||
sub2RankImagePlaceholder,
|
||||
sub2RankSourceCommitPlaceholder,
|
||||
} from "./platform-infra-sub2rank";
|
||||
import type { PacSourceArtifactBinding, PacSourceArtifactMode, PacSourceArtifactRenderer } from "./platform-infra-pipelines-as-code-source-artifact";
|
||||
|
||||
export interface Sub2RankPipelineProvenance {
|
||||
readonly configRef: string;
|
||||
readonly effectiveConfigSha256: string;
|
||||
readonly renderer: PacSourceArtifactRenderer;
|
||||
readonly mode: PacSourceArtifactMode;
|
||||
}
|
||||
|
||||
export function sub2RankOwningSourceRemote(): string {
|
||||
return readSub2RankConfig().application.remote;
|
||||
}
|
||||
|
||||
export function renderSub2RankDesiredPipeline(binding: PacSourceArtifactBinding): {
|
||||
pipeline: Record<string, unknown>;
|
||||
provenance: Sub2RankPipelineProvenance;
|
||||
} {
|
||||
const config = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(config, binding.consumer.node);
|
||||
const expectedConfigRef = `${sub2RankConfigLabel}#delivery`;
|
||||
if (binding.consumer.sourceArtifact.configRef !== expectedConfigRef) throw new Error(`Sub2Rank sourceArtifact.configRef must equal ${expectedConfigRef}`);
|
||||
assertBinding(config, target, binding);
|
||||
const manifestTemplate = renderSub2RankManifest(config, target, {
|
||||
imageRef: sub2RankImagePlaceholder,
|
||||
appConfigBase64: sub2RankConfigBase64Placeholder,
|
||||
appConfigSha256: sub2RankConfigSha256Placeholder,
|
||||
sourceCommit: sub2RankSourceCommitPlaceholder,
|
||||
});
|
||||
return {
|
||||
pipeline: renderPipeline(config, target, binding, manifestTemplate),
|
||||
provenance: {
|
||||
configRef: expectedConfigRef,
|
||||
effectiveConfigSha256: stableJsonSha256(pipelineOwner(config, target)),
|
||||
renderer: "sub2rank-platform-service",
|
||||
mode: "embedded-pipeline-spec",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderPipeline(
|
||||
config: Sub2RankConfig,
|
||||
target: Sub2RankTarget,
|
||||
binding: PacSourceArtifactBinding,
|
||||
manifestTemplate: string,
|
||||
): Record<string, unknown> {
|
||||
const delivery = config.delivery;
|
||||
const params = [
|
||||
{ name: "git-read-url", type: "string", default: binding.repository.cloneUrl },
|
||||
{ name: "source-branch", type: "string", default: config.application.branch },
|
||||
{ name: "revision", type: "string" },
|
||||
{ name: "source-stage-ref", type: "string" },
|
||||
{ name: "config-path", type: "string", default: config.application.sourceConfigPath },
|
||||
{ name: "dockerfile", type: "string", default: config.application.dockerfile },
|
||||
{ name: "image-repository", type: "string", default: config.image.repository },
|
||||
{ name: "tools-image", type: "string", default: delivery.pipeline.toolsImage },
|
||||
{ name: "buildkit-image", type: "string", default: delivery.pipeline.buildkitImage },
|
||||
{ name: "build-network", type: "string", default: delivery.build.networkMode },
|
||||
{ name: "build-http-proxy", type: "string", default: delivery.build.proxy.http },
|
||||
{ name: "build-https-proxy", type: "string", default: delivery.build.proxy.https },
|
||||
{ name: "build-all-proxy", type: "string", default: delivery.build.proxy.all },
|
||||
{ name: "build-no-proxy", type: "string", default: delivery.build.proxy.noProxy.join(",") },
|
||||
{ name: "gitops-read-url", type: "string", default: delivery.gitops.readUrl },
|
||||
{ name: "gitops-write-url", type: "string", default: delivery.gitops.writeUrl },
|
||||
{ name: "gitops-branch", type: "string", default: delivery.gitops.branch },
|
||||
{ name: "gitops-manifest-path", type: "string", default: delivery.gitops.manifestPath },
|
||||
{ name: "gitops-release-state-path", type: "string", default: delivery.gitops.releaseStatePath },
|
||||
{ name: "gitops-max-push-attempts", type: "string", default: String(delivery.gitops.maxPushAttempts) },
|
||||
{ name: "gitops-author-name", type: "string", default: delivery.gitops.author.name },
|
||||
{ name: "gitops-author-email", type: "string", default: delivery.gitops.author.email },
|
||||
{ name: "manifest-template-b64", type: "string", default: Buffer.from(manifestTemplate, "utf8").toString("base64") },
|
||||
];
|
||||
const task = (name: string, steps: readonly Record<string, unknown>[], runAfter: readonly string[] = []): Record<string, unknown> => ({
|
||||
name,
|
||||
...(runAfter.length === 0 ? {} : { runAfter }),
|
||||
workspaces: [{ name: "source", workspace: "source" }],
|
||||
taskSpec: { params: params.map(({ name }) => ({ name })), workspaces: [{ name: "source" }], steps },
|
||||
params: params.map(({ name: paramName }) => ({ name: paramName, value: `$(params.${paramName})` })),
|
||||
});
|
||||
return {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "Pipeline",
|
||||
metadata: {
|
||||
name: delivery.pipeline.name,
|
||||
namespace: delivery.pipeline.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/name": "sub2rank",
|
||||
"app.kubernetes.io/part-of": "platform-infra",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"unidesk.ai/node": target.id,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
params,
|
||||
workspaces: [{ name: "source" }],
|
||||
tasks: [
|
||||
task("source-validate", [{
|
||||
name: "checkout-and-cli-validate",
|
||||
image: "$(params.tools-image)",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: proxyEnv(),
|
||||
script: sourceValidateScript(),
|
||||
}]),
|
||||
task("image-build", [{
|
||||
name: "build-and-push",
|
||||
image: "$(params.buildkit-image)",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: [
|
||||
...proxyEnv(),
|
||||
{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" },
|
||||
{ name: "SOURCE_ROOT", value: "$(workspaces.source.path)/repo" },
|
||||
{ name: "SOURCE_COMMIT", value: "$(params.revision)" },
|
||||
{ name: "IMAGE_REPOSITORY", value: "$(params.image-repository)" },
|
||||
{ name: "DOCKERFILE", value: "$(params.dockerfile)" },
|
||||
{ name: "BUILD_NETWORK", value: "$(params.build-network)" },
|
||||
{ name: "BUILD_METADATA_FILE", value: "$(workspaces.source.path)/build-metadata.json" },
|
||||
{ name: "BUILD_RESULT_FILE", value: "$(workspaces.source.path)/build-result.json" },
|
||||
],
|
||||
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
|
||||
script: buildScript(),
|
||||
}], ["source-validate"]),
|
||||
task("gitops-publish", [{
|
||||
name: "publish-digest-manifest",
|
||||
image: "$(params.tools-image)",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: proxyEnv(),
|
||||
script: gitopsPublishScript(),
|
||||
}], ["image-build"]),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sourceValidateScript(): string {
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
"root=\"$(workspaces.source.path)\"",
|
||||
"rm -rf \"$root/repo\"",
|
||||
"git clone --filter=blob:none --no-checkout \"$(params.git-read-url)\" \"$root/repo\"",
|
||||
"cd \"$root/repo\"",
|
||||
"git fetch --depth=1 --filter=blob:none origin \"+$(params.source-stage-ref):refs/remotes/origin/sub2rank-source-snapshot\"",
|
||||
"git checkout --detach \"$(params.revision)\"",
|
||||
"test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"",
|
||||
"bun install --frozen-lockfile",
|
||||
"bun scripts/sub2rank-cli.ts --config \"$(params.config-path)\" config validate",
|
||||
"chmod -R a+rX,g+rwX \"$root/repo\"",
|
||||
"printf '{\"ok\":true,\"phase\":\"source-validate\",\"sourceCommit\":\"%s\",\"configPath\":\"%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$(params.config-path)\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildScript(): string {
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
"exec \"$(workspaces.source.path)/repo/scripts/ci/build-image.sh\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitopsPublishScript(): string {
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
"set -eu",
|
||||
"root=\"$(workspaces.source.path)\"",
|
||||
"exec bun \"$root/repo/scripts/ci/publish-gitops.mjs\" \\",
|
||||
" --source-root \"$root/repo\" \\",
|
||||
" --source-commit \"$(params.revision)\" \\",
|
||||
" --config-path \"$(params.config-path)\" \\",
|
||||
" --metadata \"$root/build-metadata.json\" \\",
|
||||
" --manifest-template-b64 \"$(params.manifest-template-b64)\" \\",
|
||||
" --image-repository \"$(params.image-repository)\" \\",
|
||||
" --gitops-read-url \"$(params.gitops-read-url)\" \\",
|
||||
" --gitops-write-url \"$(params.gitops-write-url)\" \\",
|
||||
" --gitops-branch \"$(params.gitops-branch)\" \\",
|
||||
" --manifest-path \"$(params.gitops-manifest-path)\" \\",
|
||||
" --release-state-path \"$(params.gitops-release-state-path)\" \\",
|
||||
" --max-push-attempts \"$(params.gitops-max-push-attempts)\" \\",
|
||||
" --author-name \"$(params.gitops-author-name)\" \\",
|
||||
" --author-email \"$(params.gitops-author-email)\" \\",
|
||||
" --worktree \"$root/gitops\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function proxyEnv(): Record<string, unknown>[] {
|
||||
return [
|
||||
{ name: "HTTP_PROXY", value: "$(params.build-http-proxy)" },
|
||||
{ name: "http_proxy", value: "$(params.build-http-proxy)" },
|
||||
{ name: "HTTPS_PROXY", value: "$(params.build-https-proxy)" },
|
||||
{ name: "https_proxy", value: "$(params.build-https-proxy)" },
|
||||
{ name: "ALL_PROXY", value: "$(params.build-all-proxy)" },
|
||||
{ name: "all_proxy", value: "$(params.build-all-proxy)" },
|
||||
{ name: "NO_PROXY", value: "$(params.build-no-proxy)" },
|
||||
{ name: "no_proxy", value: "$(params.build-no-proxy)" },
|
||||
];
|
||||
}
|
||||
|
||||
function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding: PacSourceArtifactBinding): void {
|
||||
const delivery = config.delivery;
|
||||
const expected: Readonly<Record<string, string>> = {
|
||||
node: target.id,
|
||||
lane: "platform-infra",
|
||||
namespace: delivery.pipeline.namespace,
|
||||
pipeline: delivery.pipeline.name,
|
||||
pipelineRunPrefix: delivery.pipeline.runPrefix,
|
||||
service_account: delivery.pipeline.serviceAccountName,
|
||||
source_branch: config.application.branch,
|
||||
source_snapshot_prefix: delivery.pipeline.sourceSnapshotPrefix,
|
||||
image_repository: config.image.repository,
|
||||
gitops_branch: delivery.gitops.branch,
|
||||
gitops_manifest_path: delivery.gitops.manifestPath,
|
||||
pipeline_name: delivery.pipeline.name,
|
||||
pipeline_run_prefix: delivery.pipeline.runPrefix,
|
||||
pipeline_timeout: delivery.pipeline.timeout,
|
||||
workspace_pvc_size: delivery.pipeline.workspaceSize,
|
||||
};
|
||||
const observed: Readonly<Record<string, string | undefined>> = {
|
||||
node: binding.consumer.node,
|
||||
lane: binding.consumer.lane,
|
||||
namespace: binding.consumer.namespace,
|
||||
pipeline: binding.consumer.pipeline,
|
||||
pipelineRunPrefix: binding.consumer.pipelineRunPrefix,
|
||||
...binding.repository.params,
|
||||
};
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (observed[key] !== value) throw new Error(`Sub2Rank PaC binding ${key}=${String(observed[key])} does not match owning YAML ${value}`);
|
||||
}
|
||||
if (binding.consumer.deliveryProvenance?.executionServiceAccountName !== delivery.pipeline.serviceAccountName) {
|
||||
throw new Error(`Sub2Rank PaC deliveryProvenance execution ServiceAccount does not match owning YAML ${delivery.pipeline.serviceAccountName}`);
|
||||
}
|
||||
const application = delivery.gitops.application;
|
||||
if (binding.consumer.argoNamespace !== application.namespace) throw new Error(`Sub2Rank PaC argoNamespace does not match owning YAML ${application.namespace}`);
|
||||
if (binding.consumer.argoApplication !== application.name) throw new Error(`Sub2Rank PaC argoApplication does not match owning YAML ${application.name}`);
|
||||
const bootstrap = binding.consumer.argoBootstrap;
|
||||
if (bootstrap === null) throw new Error("Sub2Rank PaC argoBootstrap must be declared");
|
||||
const expectedBootstrap = {
|
||||
project: application.project,
|
||||
repoUrl: delivery.gitops.readUrl,
|
||||
targetRevision: delivery.gitops.branch,
|
||||
path: application.path,
|
||||
destinationNamespace: application.destinationNamespace,
|
||||
automated: application.automated,
|
||||
} as const;
|
||||
for (const [key, value] of Object.entries(expectedBootstrap)) {
|
||||
if (bootstrap[key as keyof typeof expectedBootstrap] !== value) throw new Error(`Sub2Rank PaC argoBootstrap.${key} does not match owning YAML ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record<string, unknown> {
|
||||
return {
|
||||
application: {
|
||||
repository: config.application.repository,
|
||||
remote: config.application.remote,
|
||||
branch: config.application.branch,
|
||||
sourceConfigPath: config.application.sourceConfigPath,
|
||||
dockerfile: config.application.dockerfile,
|
||||
runtimeTarget: config.application.runtimeTarget,
|
||||
},
|
||||
image: config.image,
|
||||
delivery: config.delivery,
|
||||
target,
|
||||
runtime: config.runtime,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { startJob } from "./jobs";
|
||||
import {
|
||||
applyManifestWithExistingSecretScript,
|
||||
applyPk01CaddyBlock,
|
||||
capture,
|
||||
compactCapture,
|
||||
dryRunManifestScript,
|
||||
parseJsonOutput,
|
||||
publicDnsProbe,
|
||||
publicHttpProbe,
|
||||
@@ -20,20 +17,25 @@ import { readSub2RankConfig, resolveSub2RankTarget, sub2RankConfigLabel, type Su
|
||||
|
||||
const serviceId = "sub2rank";
|
||||
|
||||
export const sub2RankImagePlaceholder = "__SUB2RANK_IMAGE_REF__";
|
||||
export const sub2RankConfigBase64Placeholder = "__SUB2RANK_CONFIG_BASE64__";
|
||||
export const sub2RankConfigSha256Placeholder = "__SUB2RANK_CONFIG_SHA256__";
|
||||
export const sub2RankSourceCommitPlaceholder = "__SUB2RANK_SOURCE_COMMIT__";
|
||||
|
||||
export function sub2RankHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-infra sub2rank plan|apply|status|validate",
|
||||
command: "platform-infra sub2rank plan|public-exposure|status|validate",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]",
|
||||
"bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --dry-run",
|
||||
"bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2rank public-exposure [--target NC01] --dry-run",
|
||||
"bun scripts/cli.ts platform-infra sub2rank public-exposure [--target NC01] --confirm",
|
||||
"bun scripts/cli.ts platform-infra sub2rank status [--target NC01] [--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]",
|
||||
],
|
||||
configTruth: sub2RankConfigLabel,
|
||||
applicationConfigRef: "/root/sub2rank/config/sub2rank.yaml",
|
||||
artifactPolicy: "Consumes the prebuilt image declared by owning YAML; apply never runs Docker or builds from a host worktree.",
|
||||
artifactPolicy: "Application PR merge is the sole delivery trigger; PaC/Tekton builds the image and publishes a digest-pinned GitOps manifest.",
|
||||
secretPolicy: "Resolves the existing Secret declaration from config/secrets-distribution.yaml and never reads or prints runtime Secret values.",
|
||||
};
|
||||
}
|
||||
@@ -41,7 +43,7 @@ export function sub2RankHelp(): Record<string, unknown> {
|
||||
export async function runSub2RankCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1)));
|
||||
if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1)));
|
||||
if (action === "public-exposure") return await publicExposure(config, parseOpsApplyOptions(args.slice(1)));
|
||||
if (action === "status") return await status(config, parseOpsCommonOptions(args.slice(1)));
|
||||
if (action === "validate") return await validate(config, parseOpsCommonOptions(args.slice(1)));
|
||||
return { ok: false, error: "unsupported-platform-infra-sub2rank-command", args, help: sub2RankHelp() };
|
||||
@@ -50,7 +52,7 @@ export async function runSub2RankCommand(config: UniDeskConfig, args: string[]):
|
||||
function plan(options: OpsCommonOptions): Record<string, unknown> {
|
||||
const sub2rank = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(sub2rank, options.targetId);
|
||||
const yaml = renderManifest(sub2rank, target);
|
||||
const yaml = renderSub2RankManifest(sub2rank, target);
|
||||
const policy = policyChecks(sub2rank, target, yaml);
|
||||
return {
|
||||
ok: policy.every((item) => item.ok),
|
||||
@@ -59,104 +61,58 @@ function plan(options: OpsCommonOptions): Record<string, unknown> {
|
||||
config: configSummary(sub2rank, target),
|
||||
policy,
|
||||
artifact: {
|
||||
image: `${sub2rank.image.repository}:${sub2rank.image.tag}`,
|
||||
buildDisposition: "not-performed-by-apply",
|
||||
imageRepository: sub2rank.image.repository,
|
||||
buildDisposition: "pac-tekton-on-application-pr-merge",
|
||||
sourceRoot: sub2rank.application.sourceRoot,
|
||||
sourceCommit: sub2rank.application.sourceCommit,
|
||||
sourceClean: sub2rank.application.sourceClean,
|
||||
sourceRemotePresent: sub2rank.application.sourceRemotePresent,
|
||||
publication: sub2rank.application.sourceRemotePresent
|
||||
? { supported: false, blocker: "remote-source-not-registered-with-controlled-ci", required: "Register the repository as a YAML-owned PaC/Tekton source authority before publishing the image." }
|
||||
: { supported: false, blocker: "remote-source-authority-missing", required: "Create or select an independent Git remote source authority, then register it with the YAML-owned PaC/Tekton producer." },
|
||||
sourceRemoteMatches: sub2rank.application.sourceRemoteMatches,
|
||||
publication: {
|
||||
supported: sub2rank.delivery.enabled,
|
||||
authority: "GitHub PR merge -> Gitea snapshot -> PaC -> Tekton -> GitOps -> Argo",
|
||||
branch: sub2rank.application.branch,
|
||||
pipeline: sub2rank.delivery.pipeline.name,
|
||||
},
|
||||
},
|
||||
topology: `client -> PK01 Caddy -> PK01 frps:${target.publicExposure.frpc.remotePort} -> ${target.id} frpc -> ${sub2rank.runtime.service.name}.${target.namespace}.svc.cluster.local:${sub2rank.runtime.service.port}`,
|
||||
next: {
|
||||
secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope sub2rank --confirm",
|
||||
dryRun: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --dry-run`,
|
||||
apply: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --confirm`,
|
||||
publicExposureDryRun: `bun scripts/cli.ts platform-infra sub2rank public-exposure --target ${target.id} --dry-run`,
|
||||
publicExposureApply: `bun scripts/cli.ts platform-infra sub2rank public-exposure --target ${target.id} --confirm`,
|
||||
status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function apply(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
||||
async function publicExposure(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
||||
const sub2rank = readSub2RankConfig();
|
||||
const target = resolveSub2RankTarget(sub2rank, options.targetId);
|
||||
const yaml = renderManifest(sub2rank, target);
|
||||
const policy = policyChecks(sub2rank, target, yaml);
|
||||
if (!policy.every((item) => item.ok)) {
|
||||
return { ok: false, action: "platform-infra-sub2rank-apply", mode: "policy-blocked", mutation: false, target: targetSummary(target), policy };
|
||||
}
|
||||
if (options.confirm && !options.wait) {
|
||||
const job = startJob(
|
||||
`platform_infra_sub2rank_apply_${target.id.toLowerCase()}`,
|
||||
["bun", "scripts/cli.ts", "platform-infra", "sub2rank", "apply", "--target", target.id, "--confirm", "--wait"],
|
||||
`Apply ${target.id} Sub2Rank manifests and reconcile the YAML-owned PK01 Caddy site through the controlled UniDesk CLI`,
|
||||
);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
action: "platform-infra-sub2rank-apply",
|
||||
mode: "async-job",
|
||||
mutation: true,
|
||||
target: targetSummary(target),
|
||||
job,
|
||||
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
||||
next: {
|
||||
status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
||||
runtime: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const remote = await capture(config, target.route, ["sh"], dryRunManifestScript({
|
||||
yaml,
|
||||
target,
|
||||
fieldManager: sub2rank.runtime.rollout.fieldManager,
|
||||
manifestName: serviceId,
|
||||
}));
|
||||
const parsed = parseJsonOutput(remote.stdout);
|
||||
return {
|
||||
ok: remote.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-sub2rank-apply",
|
||||
action: "platform-infra-sub2rank-public-exposure",
|
||||
mode: "dry-run",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
policy,
|
||||
remote: parsed ?? compactCapture(remote, { full: true }),
|
||||
exposure: {
|
||||
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
||||
upstream: `127.0.0.1:${target.publicExposure.frpc.remotePort}`,
|
||||
managedBlock: serviceId,
|
||||
},
|
||||
next: `bun scripts/cli.ts platform-infra sub2rank public-exposure --target ${target.id} --confirm`,
|
||||
};
|
||||
}
|
||||
const resources = runtimeResources(sub2rank, target);
|
||||
const remote = await capture(config, target.route, ["sh"], applyManifestWithExistingSecretScript({
|
||||
yaml,
|
||||
target,
|
||||
fieldManager: sub2rank.runtime.rollout.fieldManager,
|
||||
manifestName: serviceId,
|
||||
secretName: resources.secretName,
|
||||
requiredSecretKeys: resources.requiredSecretKeys,
|
||||
deploymentNames: [resources.appDeploymentName, target.publicExposure.frpc.deploymentName],
|
||||
waitTimeoutSeconds: sub2rank.runtime.rollout.waitTimeoutSeconds,
|
||||
}));
|
||||
const parsed = parseJsonOutput(remote.stdout);
|
||||
const remoteOk = remote.exitCode === 0 && parsed?.ok === true;
|
||||
const caddy = remoteOk
|
||||
? await applyPk01CaddyBlock(config, serviceId, target.publicExposure)
|
||||
: { ok: false, mutation: false, disposition: "skipped-runtime-apply-failed" };
|
||||
const caddy = await applyPk01CaddyBlock(config, serviceId, target.publicExposure);
|
||||
return {
|
||||
ok: remoteOk && caddy.ok === true,
|
||||
action: "platform-infra-sub2rank-apply",
|
||||
ok: caddy.ok === true,
|
||||
action: "platform-infra-sub2rank-public-exposure",
|
||||
mode: "confirmed",
|
||||
mutation: true,
|
||||
target: targetSummary(target),
|
||||
policy,
|
||||
secret: secretSummary(sub2rank),
|
||||
remote: parsed ?? compactCapture(remote, { full: true }),
|
||||
pk01Caddy: caddy,
|
||||
next: {
|
||||
status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`,
|
||||
validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`,
|
||||
},
|
||||
next: `Merge the application PR only after the PaC repository and source snapshot chain are ready.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,10 +157,20 @@ async function validate(config: UniDeskConfig, options: OpsCommonOptions): Promi
|
||||
};
|
||||
}
|
||||
|
||||
function renderManifest(config: Sub2RankConfig, target: Sub2RankTarget): string {
|
||||
export interface Sub2RankManifestRenderOptions {
|
||||
imageRef?: string;
|
||||
appConfigBase64?: string;
|
||||
appConfigSha256?: string;
|
||||
sourceCommit?: string;
|
||||
}
|
||||
|
||||
export function renderSub2RankManifest(config: Sub2RankConfig, target: Sub2RankTarget, options: Sub2RankManifestRenderOptions = {}): string {
|
||||
const runtime = config.runtime;
|
||||
const image = `${config.image.repository}:${config.image.tag}`;
|
||||
const configHash = createHash("sha256").update(JSON.stringify({ deployment: config, target, appConfigSha256: config.application.configSha256 })).digest("hex").slice(0, 16);
|
||||
const image = options.imageRef ?? `${config.image.repository}:pac-preview`;
|
||||
const appConfigBase64 = options.appConfigBase64 ?? Buffer.from(config.application.configText, "utf8").toString("base64");
|
||||
const appConfigSha256 = options.appConfigSha256 ?? config.application.configSha256;
|
||||
const sourceCommit = options.sourceCommit ?? config.application.sourceCommit;
|
||||
const configHash = createHash("sha256").update(JSON.stringify({ deployment: deploymentHashInput(config), target })).digest("hex").slice(0, 16);
|
||||
const appEnv = runtime.secret.appEnvTargetKeys.map((key) => ` - name: ${key}\n valueFrom:\n secretKeyRef:\n name: ${runtime.secret.secretName}\n key: ${key}`).join("\n");
|
||||
const command = runtime.command.map((value) => ` - ${yamlQuoted(value)}`).join("\n");
|
||||
return `apiVersion: v1
|
||||
@@ -216,9 +182,8 @@ metadata:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
data:
|
||||
${runtime.configMap.key}: |
|
||||
${indent(config.application.configText, 4)}
|
||||
binaryData:
|
||||
${runtime.configMap.key}: ${yamlQuoted(appConfigBase64)}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
@@ -276,15 +241,16 @@ spec:
|
||||
app.kubernetes.io/name: ${runtime.service.name}
|
||||
app.kubernetes.io/part-of: platform-infra
|
||||
annotations:
|
||||
unidesk.ai/sub2rank-config-sha256: "${config.application.configSha256}"
|
||||
unidesk.ai/sub2rank-config-sha256: "${appConfigSha256}"
|
||||
unidesk.ai/sub2rank-deployment-hash: "${configHash}"
|
||||
unidesk.ai/source-commit: "${sourceCommit}"
|
||||
unidesk.ai/public-base-url: "${target.publicExposure.publicBaseUrl}"
|
||||
spec:
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: ${runtime.service.name}
|
||||
image: ${image}
|
||||
image: ${yamlQuoted(image)}
|
||||
imagePullPolicy: ${config.image.pullPolicy}
|
||||
command:
|
||||
${command}
|
||||
@@ -357,7 +323,13 @@ function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record<s
|
||||
return {
|
||||
configRefs: configRefsSummary(config),
|
||||
target: targetSummary(target),
|
||||
image: `${config.image.repository}:${config.image.tag}`,
|
||||
image: { repository: config.image.repository, pullPolicy: config.image.pullPolicy, authority: "PaC digest pin" },
|
||||
delivery: {
|
||||
pipeline: config.delivery.pipeline.name,
|
||||
gitopsBranch: config.delivery.gitops.branch,
|
||||
gitopsManifestPath: config.delivery.gitops.manifestPath,
|
||||
argoApplication: config.delivery.gitops.application.name,
|
||||
},
|
||||
runtime: {
|
||||
service: `${config.runtime.service.name}.${target.namespace}.svc.cluster.local:${config.runtime.service.port}`,
|
||||
storage: { claimName: config.runtime.storage.claimName, size: config.runtime.storage.size, mountPath: config.runtime.storage.mountPath },
|
||||
@@ -383,6 +355,7 @@ function configRefsSummary(config: Sub2RankConfig): Record<string, unknown> {
|
||||
sourceCommit: config.application.sourceCommit,
|
||||
sourceClean: config.application.sourceClean,
|
||||
sourceRemotePresent: config.application.sourceRemotePresent,
|
||||
sourceRemoteMatches: config.application.sourceRemoteMatches,
|
||||
configMatchesSourceCommit: config.application.configMatchesSourceCommit,
|
||||
},
|
||||
secret: { path: config.runtime.secret.configRef, declarationId: config.runtime.secret.declarationId },
|
||||
@@ -421,9 +394,18 @@ function runtimeResources(config: Sub2RankConfig, target: Sub2RankTarget): Publi
|
||||
};
|
||||
}
|
||||
|
||||
function indent(value: string, spaces: number): string {
|
||||
const prefix = " ".repeat(spaces);
|
||||
return value.trimEnd().split("\n").map((line) => `${prefix}${line}`).join("\n");
|
||||
function deploymentHashInput(config: Sub2RankConfig): Record<string, unknown> {
|
||||
return {
|
||||
application: {
|
||||
repository: config.application.repository,
|
||||
branch: config.application.branch,
|
||||
sourceConfigPath: config.application.sourceConfigPath,
|
||||
dockerfile: config.application.dockerfile,
|
||||
runtimeTarget: config.application.runtimeTarget,
|
||||
},
|
||||
image: config.image,
|
||||
runtime: config.runtime,
|
||||
};
|
||||
}
|
||||
|
||||
function yamlQuoted(value: string): string {
|
||||
|
||||
+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