feat: add artifact publish preflight
This commit is contained in:
+342
-37
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user