feat: add artifact publish preflight

This commit is contained in:
Codex
2026-05-20 21:49:50 +00:00
parent 68ae2722ab
commit 021a9eef01
10 changed files with 636 additions and 45 deletions
+342 -37
View File
@@ -7,6 +7,12 @@ import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "./deploy-ssh-identity";
import { startJob } from "./jobs";
import { coreInternalFetch } from "./microservices";
import {
artifactRegistryReadonlyResultFromCommand,
buildArtifactRegistryReadonlyProbe,
parseArtifactRegistryOptions,
type ArtifactRegistryReadonlyProbe,
} from "./artifact-registry";
const d601ProviderId = "D601";
const d601Kubeconfig = "/etc/rancher/k3s/k3s.yaml";
@@ -81,6 +87,34 @@ interface DispatchResult {
raw: unknown;
}
interface PublishPreflightChannelProbe {
channel: "backend-core-api" | "provider-dispatch" | "provider-host-ssh" | "database" | "artifact-registry";
ok: boolean;
requiredFor: string;
detail: unknown;
}
interface PublishPreflight {
ok: boolean;
runnerDisposition: "ready" | "infra-blocked";
serviceId: string;
commit: string;
providerId: string;
supportedArtifactPublish: boolean;
missingChannels: string[];
channels: PublishPreflightChannelProbe[];
registry: unknown;
next: string[];
boundary: string;
}
export interface PublishPreflightTransport {
coreFetch: (path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }) => unknown | Promise<unknown>;
dispatchHostSsh: (command: string, waitMs: number, remoteTimeoutMs: number) => Promise<DispatchResult>;
commandCwd: string;
artifactRegistryCommand: (probe: ArtifactRegistryReadonlyProbe) => string[];
}
interface PipelineRunCondition {
ok: boolean | null;
status: string;
@@ -262,6 +296,42 @@ function blockedReason(artifact: CiSourceBuildCatalogArtifact): string {
return artifact.blockedReason;
}
function userServicePublishBoundaryBlock(
config: UniDeskConfig,
serviceId: string,
commit: string,
artifact: CiSourceBuildCatalogArtifact,
): Record<string, unknown> | null {
const configService = config.microservices.find((item) => item.id === serviceId);
if (configService === undefined) return null;
const isD601K3sService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "k3sctl-managed";
const isD601DirectService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "unidesk-direct";
const isMainServerDirectService = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "internal-sidecar";
if (isD601K3sService || isD601DirectService || isMainServerDirectService || isMainServerInternalSidecar) return null;
return {
ok: false,
status: "blocked",
error: "blocked",
serviceId,
commit,
reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
catalogArtifact: artifact,
configService: {
providerId: configService.providerId,
deploymentMode: configService.deployment.mode,
},
};
}
function chunks(value: string, size: number): string[] {
const result: string[] = [];
for (let index = 0; index < value.length; index += size) {
@@ -282,6 +352,100 @@ function coreBody(response: unknown): Record<string, unknown> | null {
return asRecord(asRecord(response)?.body);
}
function responseOk(response: unknown): boolean {
if (typeof response !== "object" || response === null) return false;
const record = response as Record<string, unknown>;
if ("ok" in record && record.ok === false) return false;
const body = asRecord(record.body);
if (body !== null && "ok" in body && body.ok === false) return false;
return true;
}
function channelProbe(
channel: PublishPreflightChannelProbe["channel"],
ok: boolean,
requiredFor: string,
detail: unknown,
): PublishPreflightChannelProbe {
return { channel, ok, requiredFor, detail };
}
function backendCoreUnavailable(value: unknown): boolean {
const record = asRecord(value);
if (record?.runnerDisposition === "infra-blocked") return true;
if (record?.failureKind === "target-stack-not-running") return true;
const text = JSON.stringify(value) ?? "";
return text.includes("No such container: unidesk-backend-core")
|| text.includes("No such container: unidesk-database");
}
function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult {
return {
ok: false,
taskId: result.taskId,
status: result.status,
stdout: result.stdout.slice(-4000),
stderr: result.stderr.slice(-4000),
exitCode: result.exitCode,
raw: {
command,
taskId: result.taskId,
status: result.status,
exitCode: result.exitCode,
stderrTail: result.stderr.slice(-1200),
stdoutTail: result.stdout.slice(-1200),
raw: result.raw,
},
};
}
function commandResultFromDispatch(command: string[], cwd: string, result: DispatchResult) {
return {
command,
cwd,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
signal: null,
timedOut: result.status === "timeout",
};
}
async function dispatchReadonlySsh(command: string, waitMs: number, remoteTimeoutMs: number): Promise<DispatchResult> {
try {
const result = await dispatchSsh(command, waitMs, remoteTimeoutMs);
if (!result.ok && backendCoreUnavailable(result.raw)) {
return {
...result,
status: "infra-blocked",
stderr: "backend-core bridge unavailable while dispatching readonly SSH task",
};
}
return result;
} catch (error) {
return {
ok: false,
taskId: null,
status: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
exitCode: null,
raw: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack ?? null } : String(error),
};
}
}
function artifactRegistryProbeCommand(probe: ArtifactRegistryReadonlyProbe): string[] {
return [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script];
}
const localPublishPreflightTransport: PublishPreflightTransport = {
coreFetch: (path, init) => coreInternalFetch(path, init),
dispatchHostSsh: dispatchReadonlySsh,
commandCwd: repoRoot,
artifactRegistryCommand: artifactRegistryProbeCommand,
};
function positiveManifestNumber(value: unknown, fallback: number, path: string): number {
if (value === undefined || value === null) return fallback;
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer`);
@@ -1117,6 +1281,92 @@ function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: s
}
}
async function publishUserServicePreflight(
_config: UniDeskConfig,
options: CiPublishUserServiceArtifactOptions,
plannedArtifact: ArtifactSummary,
transport: PublishPreflightTransport,
): Promise<PublishPreflight> {
const providerId = d601ProviderId;
const channels: PublishPreflightChannelProbe[] = [];
const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 });
const overviewBody = coreBody(overview);
const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true;
channels.push(channelProbe("backend-core-api", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", {
ok: responseOk(overview),
dbReady: overviewBody?.dbReady ?? null,
runnerDisposition: asRecord(overview)?.runnerDisposition ?? null,
failureKind: asRecord(overview)?.failureKind ?? null,
detail: backendCoreUnavailable(overview) ? overview : {
status: asRecord(overview)?.status ?? null,
body: overviewBody,
},
}));
channels.push(channelProbe("database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", {
dbReady: overviewBody?.dbReady ?? false,
observedThrough: "backend-core /api/overview",
}));
const probeScript = [
"set -euo pipefail",
"printf 'provider_host_ssh=ok\\n'",
"command -v bash >/dev/null",
"command -v docker >/dev/null",
"command -v kubectl >/dev/null",
"test -S /var/run/docker.sock || test -S /run/docker.sock || true",
].join("\n");
const sshProbe = await transport.dispatchHostSsh(probeScript, 30_000, 15_000);
channels.push(channelProbe("provider-dispatch", sshProbe.taskId !== null || sshProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", {
taskId: sshProbe.taskId,
status: sshProbe.status,
ok: sshProbe.taskId !== null || sshProbe.ok,
exitCode: sshProbe.exitCode,
stderrTail: sshProbe.stderr.slice(-1200),
}));
channels.push(channelProbe("provider-host-ssh", sshProbe.ok, "D601 source export, registry checks, kubectl/Tekton submission, and artifact summary reads", {
taskId: sshProbe.taskId,
status: sshProbe.status,
exitCode: sshProbe.exitCode,
stdoutTail: sshProbe.stdout.slice(-1200),
stderrTail: sshProbe.stderr.slice(-1200),
raw: sshProbe.ok ? undefined : dispatchPreflightFailure("host.ssh readonly probe", sshProbe).raw,
}));
const registryOptions = parseArtifactRegistryOptions(["--provider-id", providerId]);
const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions);
const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs);
const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch);
const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand);
const registryRecord = asRecord(registry);
const registryOk = registryRecord?.ok === true || registryRecord?.runtimeApiHealthy === true;
channels.push(channelProbe("artifact-registry", registryOk, "commit-pinned image push and later CD manifest checks", registry));
const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel);
const ready = missingChannels.length === 0;
return {
ok: ready,
runnerDisposition: ready ? "ready" : "infra-blocked",
serviceId: options.serviceId,
commit: options.commit,
providerId,
supportedArtifactPublish: true,
missingChannels,
channels,
registry,
next: ready
? [
`bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
`later CD must consume ${plannedArtifact.imageRef}; CI itself must not deploy production`,
]
: [
"Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.",
"Restore backend-core/database/provider-gateway/Host SSH connectivity before retrying artifact publication.",
"Use bun scripts/cli.ts artifact-registry health --provider-id D601 to recheck registry reachability after the control bridge is restored.",
],
boundary: "preflight is read-only: no D601 source export, no Tekton PipelineRun, no image push, no deploy apply, no service restart",
};
}
async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
@@ -1274,17 +1524,23 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
if (options.dryRun) {
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, localPublishPreflightTransport);
return {
ok: true,
mode: "dry-run",
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
channels: preflight.channels,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: true,
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
@@ -1299,10 +1555,8 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation",
next: [
`bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
],
boundary: preflight.boundary,
next: preflight.next,
};
}
const source = options.serviceId === "claudeqq"
@@ -1343,6 +1597,85 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
};
}
export async function runCiPublishUserServiceDryRunPreflight(
config: UniDeskConfig,
args: string[],
transport: PublishPreflightTransport,
): Promise<Record<string, unknown>> {
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
if (!args.includes("--dry-run")) throw new Error("publish-user-service preflight requires --dry-run");
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-user-service reads source repo from CI.json; edit CI.json instead of using --repo");
}
const artifact = resolveCatalogArtifact(serviceId);
if (artifact.kind === "source-build" && artifact.serviceId === "backend-core") {
throw new Error("backend-core uses ci publish-backend-core; publish-user-service is for registered user services");
}
if (artifact.kind === "upstream-image") {
return blockedArtifactResult(artifact, commit, artifact.blockedReason);
}
if (artifact.status === "blocked") {
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
}
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
if (boundaryBlock !== null) return boundaryBlock;
const summaryContext: ArtifactSummaryContext = {
serviceId,
dockerfile,
commit,
repoUrl: artifact.source.repo,
imageRepository: artifact.image.repository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const options: CiPublishUserServiceArtifactOptions = {
repoUrl: artifact.source.repo,
commit,
waitMs: numberOption(args, "--wait-ms", 0),
serviceId,
dockerfile,
imageRepository: artifact.image.repository,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun: true,
};
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, transport);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
return {
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
channels: preflight.channels,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: plannedRepoFetchUrl,
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}),
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
boundary: preflight.boundary,
next: preflight.next,
};
}
async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise<DispatchResult> {
const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000);
const remoteTimeoutMs = 45_000;
@@ -1727,36 +2060,8 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
}
const repoUrl = artifact.source.repo;
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const configService = config.microservices.find((item) => item.id === serviceId);
if (configService !== undefined) {
const isD601K3sService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "k3sctl-managed";
const isD601DirectService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "unidesk-direct";
const isMainServerDirectService = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "internal-sidecar";
if (!isD601K3sService && !isD601DirectService && !isMainServerDirectService && !isMainServerInternalSidecar) {
return {
ok: false,
status: "blocked",
error: "blocked",
serviceId,
commit,
reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
catalogArtifact: artifact,
configService: {
providerId: configService.providerId,
deploymentMode: configService.deployment.mode,
},
};
}
}
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
if (boundaryBlock !== null) return boundaryBlock;
return publishUserServiceArtifact(config, {
repoUrl,
commit,