standardize user-service artifact CD

This commit is contained in:
Codex
2026-05-19 10:07:25 +00:00
parent 292d0cee60
commit 1e326cb8fc
5 changed files with 591 additions and 50 deletions
+118 -7
View File
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
import { runCommand } from "./command";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity";
import { runArtifactRegistryCommand } from "./artifact-registry";
import { startJob } from "./jobs";
import { coreInternalFetch } from "./microservices";
@@ -27,6 +28,7 @@ interface DeployOptions {
file: string;
environment: DeployEnvironment | null;
serviceId: string | null;
commitOverride: string | null;
runNow: boolean;
dryRun: boolean;
force: boolean;
@@ -133,6 +135,7 @@ const nativeK3sCtrAddress = "/run/k3s/containerd/containerd.sock";
const unideskRepoUrl = "https://github.com/pikasTech/unidesk";
const d601MaintenanceDeployAllowedServiceIds = new Set<string>(["backend-core", "frontend", "k3sctl-adapter", "code-queue"]);
const devApplySupportedServiceIds = new Set<string>(["backend-core", "frontend"]);
const prodArtifactConsumerServiceIds = new Set<string>(["backend-core", "decision-center"]);
const deployEnvironmentTargets: Record<DeployEnvironment, DeployEnvironmentTarget> = {
dev: {
environment: "dev",
@@ -186,7 +189,7 @@ export function deployHelp(action: string | undefined = undefined): Record<strin
usage: {
check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]",
plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]",
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev] [--service id] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--run-now]",
},
actions: {
check: "Validate desired repo+commit state against live service health and commit markers.",
@@ -195,8 +198,9 @@ export function deployHelp(action: string | undefined = undefined): Record<strin
},
options: [
{ name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local manifest apply allows k3sctl-adapter and explicit production code-queue controlled rollout on D601." },
{ name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Dev apply is enabled only for backend-core and frontend in D601 unidesk-dev; prod apply is disabled." },
{ name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Dev apply is enabled only for backend-core and frontend in D601 unidesk-dev; prod apply uses the D601 registry artifact consumer for backend-core and decision-center." },
{ name: "--service <id>", description: "Limit reconcile to one service from the manifest." },
{ name: "--commit <full-sha>", description: "Prod artifact rollback/apply override for a selected service; the image must already exist in D601 registry." },
{ name: "--dry-run", description: "Prepare and validate without mutating the target service." },
{ name: "--force", description: "Redeploy even when the live commit appears up to date." },
{ name: "--timeout-ms <n>", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." },
@@ -464,10 +468,16 @@ function environmentOption(args: string[]): DeployEnvironment | null {
function parseOptions(args: string[]): DeployOptions {
const environment = environmentOption(args);
if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file");
const commitOverride = optionValue(args, ["--commit"]);
const serviceId = optionValue(args, ["--service", "--service-id"]) ?? null;
if (commitOverride !== undefined && environment !== "prod") throw new Error("deploy --commit is only supported for --env prod artifact rollback/apply");
if (commitOverride !== undefined && !isFullGitSha(commitOverride)) throw new Error("deploy --commit must be a full 40-character commit SHA");
if (commitOverride !== undefined && serviceId === null) throw new Error("deploy --commit requires --service so prod rollback/apply is unambiguous");
return {
file: optionValue(args, ["--file"]) ?? defaultDeployFile,
environment,
serviceId: optionValue(args, ["--service", "--service-id"]) ?? null,
serviceId,
commitOverride: commitOverride?.toLowerCase() ?? null,
runNow: args.includes("--run-now"),
dryRun: args.includes("--dry-run"),
force: args.includes("--force"),
@@ -2416,15 +2426,19 @@ function environmentDryRunPlan(
const environment = options.environment;
if (environment === null) throw new Error("environment dry-run requires --env");
const target = deployEnvironmentTargets[environment];
const services = options.serviceId === null
const selectedServices = options.serviceId === null
? manifest.services
: manifest.services.filter((service) => service.id === options.serviceId);
if (options.serviceId !== null && services.length === 0) {
if (options.serviceId !== null && selectedServices.length === 0) {
throw new Error(`deploy manifest ${source.ref}:deploy.json does not contain service: ${options.serviceId}`);
}
const services = selectedServices.map((service) => ({
...service,
commitId: options.commitOverride !== null && service.id === options.serviceId ? options.commitOverride : service.commitId,
}));
const fingerprint = databaseFingerprint(target);
return {
ok: true,
ok: environment === "prod" ? services.every((service) => prodArtifactConsumerServiceIds.has(service.id)) : true,
action,
mode: "environment-ref-dry-run",
dryRun: true,
@@ -2458,6 +2472,18 @@ function environmentDryRunPlan(
targetNamespace: target.namespace,
providerId: target.provider.providerId,
databaseFingerprint: fingerprint,
deploymentPath: environment === "prod"
? prodArtifactConsumerServiceIds.has(service.id)
? "d601-registry-artifact-consumer"
: "unsupported"
: "d601-dev-target-side-build",
unsupported: environment === "prod" && !prodArtifactConsumerServiceIds.has(service.id)
? {
ok: false,
supported: false,
reason: "No standardized prod D601 registry artifact consumer is implemented for this service; legacy maintenance-channel deployment is not allowed.",
}
: undefined,
})),
};
}
@@ -2479,6 +2505,86 @@ function d601MaintenanceDeployBlockMessage(blocked: string[]): string {
return `D601 target-side deployment is enabled only for k3sctl-adapter and dev backend-core/frontend; blocked services: ${blocked.join(", ")}. Use ci run-dev-e2e for dev smoke verification.`;
}
function selectedEnvironmentServices(manifest: DeployManifest, serviceId: string | null): DeployManifestService[] {
const services = serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === serviceId);
if (serviceId !== null && services.length === 0) throw new Error(`deploy manifest does not contain service: ${serviceId}`);
return services;
}
function prodArtifactUnsupportedServices(manifest: DeployManifest, serviceId: string | null): DeployManifestService[] {
return selectedEnvironmentServices(manifest, serviceId).filter((service) => !prodArtifactConsumerServiceIds.has(service.id));
}
function prodArtifactUnsupportedResult(services: DeployManifestService[]): Record<string, unknown> {
return {
ok: false,
supported: false,
error: "unsupported",
services: services.map((service) => ({
id: service.id,
repo: service.repo,
commitId: service.commitId,
supported: false,
reason: "No standardized prod D601 registry artifact consumer is implemented for this service.",
})),
policy: "prod deploy must not silently fall back to legacy D601 maintenance-channel source builds",
supportedServices: Array.from(prodArtifactConsumerServiceIds),
};
}
async function runProdArtifactApplyNow(manifest: DeployManifest, options: DeployOptions): Promise<Record<string, unknown>> {
const selected = selectedEnvironmentServices(manifest, options.serviceId);
const unsupported = selected.filter((service) => !prodArtifactConsumerServiceIds.has(service.id));
if (unsupported.length > 0) return prodArtifactUnsupportedResult(unsupported);
const startedAt = nowIso();
const results = [];
for (const service of selected) {
const commit = options.commitOverride ?? service.commitId;
const artifactArgs = [
"deploy-service",
"--service", service.id,
"--commit", commit,
"--source-repo", service.repo,
"--timeout-ms", String(options.timeoutMs),
"--run-now",
...(options.dryRun ? ["--dry-run"] : []),
];
progressLine("prod-artifact-cd", options.dryRun ? "dry-run" : "reconcile", { serviceId: service.id, commit });
results.push(await runArtifactRegistryCommand(artifactArgs));
const latest = results.at(-1) as Record<string, unknown>;
if (latest.ok !== true) break;
}
return {
ok: results.every((result) => asRecord(result)?.ok === true),
action: "apply",
environment: "prod",
executor: "d601-registry-artifact-consumer",
dryRun: options.dryRun,
startedAt,
finishedAt: nowIso(),
results,
};
}
function prodArtifactApplyJob(args: string[], options: DeployOptions): Record<string, unknown> {
if (!options.runNow && options.dryRun) {
throw new Error("deploy apply --env prod --dry-run should run in the foreground so the plan is visible immediately");
}
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
const command = [process.execPath, rootPath("scripts", "cli.ts"), "deploy", ...runArgs];
const source = `${deployEnvironmentTargets.prod.gitRef}:deploy.json#environments.prod`;
const job = startJob("deploy_prod_artifact_apply", command, `Deploy prod artifact consumer from ${source}${options.serviceId === null ? "" : ` service=${options.serviceId}`}`);
return {
ok: true,
mode: "async-job",
executor: "d601-registry-artifact-consumer",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
note: "Production CD continues in the background: D601 registry commit-pinned image check, pull/import, rollout, image-label and live health commit verification.",
};
}
async function runApplyNow(config: UniDeskConfig, manifest: DeployManifest, options: DeployOptions): Promise<Record<string, unknown>> {
const selected = selectServices(config, manifest, options.serviceId);
const startedAt = nowIso();
@@ -2525,7 +2631,12 @@ export async function runDeployCommand(config: UniDeskConfig | null, args: strin
if (options.environment !== null) {
const { manifest, source } = readEnvironmentDeployManifest(options.environment);
if (action === "check" || action === "plan") return environmentDryRunPlan(manifest, source, options, action);
if (options.environment !== "dev") throw new Error("deploy apply --env prod is not enabled yet");
if (options.environment === "prod") {
const unsupported = prodArtifactUnsupportedServices(manifest, options.serviceId);
if (unsupported.length > 0) return prodArtifactUnsupportedResult(unsupported);
if (options.dryRun || options.runNow) return await runProdArtifactApplyNow(manifest, options);
return prodArtifactApplyJob(args, options);
}
const unsupported = unsupportedDevApplyServices(manifest, options.serviceId);
if (unsupported.length > 0) {
throw new Error(`deploy apply --env dev currently supports only backend-core and frontend; unsupported selected services: ${unsupported.join(", ")}. Use ci run-dev-e2e for smoke verification.`);