fix: remoteize backend-core publish preflight

This commit is contained in:
Codex
2026-05-21 10:08:56 +00:00
parent b69c2fe599
commit 1749897d1a
3 changed files with 535 additions and 88 deletions
@@ -1,5 +1,6 @@
import { readConfig } from "./src/config";
import { runCiPublishBackendCoreDryRunPreflight, type PublishPreflightTransport } from "./src/ci";
import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote";
type JsonRecord = Record<string, unknown>;
@@ -15,7 +16,125 @@ function asRecord(value: unknown, label: string): JsonRecord {
const commit = "0123456789abcdef0123456789abcdef01234567";
const config = readConfig();
const readyTransport: PublishPreflightTransport = {
function healthyRegistryStdout(): string {
return [
"systemctl_available=true",
"docker_available=true",
"curl_available=true",
"unit_exists=true",
"unit_active=active",
"unit_enabled=enabled",
"compose_exists=true",
"config_exists=true",
"storage_exists=true",
"container_running=true",
"container_status=running",
"container_image=registry:2.8.3",
"container_restart_policy=always",
"listener_count=1",
"bad_listener_count=0",
"loopback_only=true",
"v2_http_code=200",
"image_matches=true",
"config_hash_matches=true",
"compose_hash_matches=true",
"unit_hash_matches=true",
].join("\n");
}
function readyTransport(kind: "remote-frontend" | "local-docker" = "remote-frontend"): PublishPreflightTransport {
return {
kind,
remoteHost: kind === "remote-frontend" ? "https://example.invalid" : null,
coreFetch: async () => ({
ok: true,
status: 200,
body: {
ok: true,
dbReady: true,
},
}),
dispatchHostSsh: async (command) => {
if (command.includes("kubectl get namespace unidesk-ci")) {
return {
ok: true,
taskId: "task-ci-runner",
status: "succeeded",
stdout: [
"provider_host_ssh=ok",
"kubectl=ok",
"docker=ok",
"docker_socket=true",
"namespace=true",
"tekton_pipeline=true",
"tekton_task=true",
"service_account=true",
"pvc=true",
"source_parent_directory=true",
].join("\n"),
stderr: "",
exitCode: 0,
raw: {},
};
}
if (command.includes("/v2/")) {
return {
ok: true,
taskId: "task-registry",
status: "succeeded",
stdout: healthyRegistryStdout(),
stderr: "",
exitCode: 0,
raw: {},
};
}
return {
ok: true,
taskId: "task-host-ssh",
status: "succeeded",
stdout: "provider_host_ssh=ok\n",
stderr: "",
exitCode: 0,
raw: {},
};
},
commandCwd: "/workspace/unidesk",
artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape],
};
}
const localMissingTransport: PublishPreflightTransport = {
kind: "local-docker",
coreFetch: async () => ({
ok: false,
status: 503,
body: {
ok: false,
failureKind: "target-stack-not-running",
runnerDisposition: "infra-blocked",
degradedReason: "backend-core-container-missing",
message: "backend-core unavailable from runner-local Docker",
},
}),
dispatchHostSsh: async (command, waitMs, remoteTimeoutMs) => ({
ok: false,
taskId: null,
status: "infra-blocked",
stdout: "",
stderr: `backend-core bridge unavailable while dispatching readonly SSH task (${waitMs}/${remoteTimeoutMs})`,
exitCode: null,
raw: {
ok: false,
failureKind: "target-stack-not-running",
runnerDisposition: "infra-blocked",
command,
},
}),
commandCwd: "/workspace/unidesk",
artifactRegistryCommand: (probe) => ["mock", probe.action, probe.remoteCommandShape],
};
const remoteInfraBlockedTransport: PublishPreflightTransport = {
kind: "remote-frontend",
remoteHost: "https://example.invalid",
coreFetch: async () => ({
@@ -27,28 +146,7 @@ const readyTransport: PublishPreflightTransport = {
},
}),
dispatchHostSsh: async (command) => {
if (command.includes("kubectl get namespace unidesk-ci")) {
return {
ok: true,
taskId: "task-ci-runner",
status: "succeeded",
stdout: [
"kubectl=ok",
"docker=ok",
"docker_socket=true",
"namespace=true",
"tekton_pipeline=true",
"tekton_task=true",
"service_account=true",
"pvc=true",
"source_parent_directory=true",
].join("\n"),
stderr: "",
exitCode: 0,
raw: {},
};
}
if (command.includes("v2/")) {
if (command.includes("/v2/")) {
return {
ok: true,
taskId: "task-registry",
@@ -63,14 +161,14 @@ const readyTransport: PublishPreflightTransport = {
"compose_exists=true",
"config_exists=true",
"storage_exists=true",
"container_running=true",
"container_status=running",
"container_running=false",
"container_status=exited",
"container_image=registry:2.8.3",
"container_restart_policy=always",
"listener_count=1",
"listener_count=0",
"bad_listener_count=0",
"loopback_only=true",
"v2_http_code=200",
"loopback_only=false",
"v2_http_code=000",
"image_matches=true",
"config_hash_matches=true",
"compose_hash_matches=true",
@@ -83,9 +181,20 @@ const readyTransport: PublishPreflightTransport = {
}
return {
ok: true,
taskId: "task-host-ssh",
taskId: "task-ci-runner",
status: "succeeded",
stdout: "provider_host_ssh=ok\n",
stdout: [
"provider_host_ssh=ok",
"kubectl=ok",
"docker=ok",
"docker_socket=true",
"namespace=true",
"tekton_pipeline=true",
"tekton_task=true",
"service_account=true",
"pvc=true",
"source_parent_directory=true",
].join("\n"),
stderr: "",
exitCode: 0,
raw: {},
@@ -96,22 +205,55 @@ const readyTransport: PublishPreflightTransport = {
};
async function main(): Promise<void> {
const autoRemote = autoRemoteCiPublishUserServiceDryRunPlan(config, [
"ci",
"publish-backend-core",
"--commit",
commit,
"--dry-run",
], {
CODE_QUEUE_SERVICE_ROLE: "scheduler",
CODE_QUEUE_DEV_CONTAINER_MASTER_HOST: "74.48.78.17",
});
assertCondition(autoRemote.enabled === true, "Code Queue runner backend-core dry-run should auto-select remote frontend transport", autoRemote);
assertCondition(autoRemote.host === "74.48.78.17", "auto remote backend-core plan should use CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", autoRemote);
assertCondition(String(autoRemote.command ?? "").includes("ci publish-backend-core"), "auto remote command should preserve backend-core publish dry-run", autoRemote);
const localMissing = await runCiPublishBackendCoreDryRunPreflight(config, [
"publish-backend-core",
"--commit",
commit,
"--dry-run",
], localMissingTransport);
const localMissingRecord = asRecord(localMissing, "local missing backend-core preflight");
const localMissingControlPlane = asRecord(localMissingRecord.remoteControlPlaneCandidate, "local missing control plane");
const localBlockedScopes = Array.isArray(localMissingRecord.blockedScopes) ? localMissingRecord.blockedScopes as string[] : [];
assertCondition(localMissingRecord.ok === false, "local missing backend-core preflight should fail", localMissingRecord);
assertCondition(localMissingRecord.failureClassification === "local-docker-required", "local Docker absence should classify local-docker-required", localMissingRecord);
assertCondition(localMissingControlPlane.transport === "local-docker", "local missing control plane should state local transport", localMissingControlPlane);
assertCondition(localMissingControlPlane.remoteCapable === false, "local missing control plane should not claim remote capable", localMissingControlPlane);
assertCondition(String(localMissingControlPlane.remoteCommandShape ?? "").includes("publish-backend-core"), "fallback should expose backend-core remote command shape", localMissingControlPlane);
assertCondition(localBlockedScopes.includes("local-docker-control-plane"), "local missing blockedScopes should expose local-docker-control-plane", localMissingRecord);
assertCondition(asRecord(localMissingRecord.d601Ci, "local missing d601Ci").dryRunWillCompileRust === false, "local dry-run must not compile Rust", localMissingRecord);
const result = await runCiPublishBackendCoreDryRunPreflight(config, [
"publish-backend-core",
"--commit",
commit,
"--dry-run",
], readyTransport);
], readyTransport());
const record = asRecord(result, "backend-core preflight");
const source = asRecord(record.source, "source");
const sourceAuth = asRecord(record.sourceAuth, "sourceAuth");
const d601Ci = asRecord(record.d601Ci, "d601Ci");
const ciRunner = asRecord(record.ciRunner, "ciRunner");
const artifactSummary = asRecord(record.artifactSummary, "artifactSummary");
const artifactRequirements = asRecord(record.artifactRequirements, "artifactRequirements");
const requiredLabels = asRecord(artifactRequirements.requiredLabels, "requiredLabels");
const devApplyPath = asRecord(record.devApplyPath, "devApplyPath");
const controlledPublish = asRecord(record.controlledPublish, "controlledPublish");
const controlPlane = asRecord(record.remoteControlPlaneCandidate, "remoteControlPlaneCandidate");
const blockedScopes = Array.isArray(record.blockedScopes) ? record.blockedScopes as string[] : [];
assertCondition(record.ok === true, "ready backend-core preflight should pass", record);
@@ -122,6 +264,10 @@ async function main(): Promise<void> {
assertCondition(record.wouldBuildOnD601 === true, "preflight should state real publish would build on D601", record);
assertCondition(record.dryRunBuildStarted === false, "dry-run must not start a build", record);
assertCondition(blockedScopes.length === 0, "ready preflight should have no blocked scopes", record);
assertCondition(record.failureClassification === null, "ready preflight should not classify a failure", record);
assertCondition(ciRunner.environment === "D601", "ciRunner should name D601", ciRunner);
assertCondition(controlPlane.transport === "remote-frontend", "ready remote preflight should expose remote frontend transport", controlPlane);
assertCondition(String(controlPlane.remoteCommandShape ?? "").includes("publish-backend-core"), "controlPlane command shape should be backend-core-specific", controlPlane);
assertCondition(source.repoFetchUrl === "git@github.com:pikasTech/unidesk.git", "source auth should use GitHub SSH fetch URL", source);
assertCondition(sourceAuth.egressProxy === "http://127.0.0.1:18789", "source auth should name provider egress proxy", sourceAuth);
assertCondition(d601Ci.wouldBuildOnD601 === true, "D601 CI runner preflight should expose D601 build boundary", d601Ci);
@@ -136,14 +282,37 @@ async function main(): Promise<void> {
assertCondition(String(record.boundary ?? "").includes("no Rust compile"), "boundary should explicitly forbid Rust compile during dry-run", record);
assertCondition(String(record.recommendedAction ?? "").includes("ci publish-backend-core"), "recommended action should name real publish command", record);
const remoteBlocked = await runCiPublishBackendCoreDryRunPreflight(config, [
"publish-backend-core",
"--commit",
commit,
"--dry-run",
], remoteInfraBlockedTransport);
const remoteBlockedRecord = asRecord(remoteBlocked, "remote infra-blocked backend-core preflight");
const remoteBlockedControlPlane = asRecord(remoteBlockedRecord.remoteControlPlaneCandidate, "remote infra-blocked control plane");
const remoteBlockedScopes = Array.isArray(remoteBlockedRecord.blockedScopes) ? remoteBlockedRecord.blockedScopes as string[] : [];
assertCondition(remoteBlockedRecord.ok === false, "remote infra-blocked preflight should fail", remoteBlockedRecord);
assertCondition(remoteBlockedRecord.failureClassification === "registry-unhealthy" || remoteBlockedRecord.failureClassification === "registry-not-installed", "remote infra-blocked should classify registry failure, not local docker", remoteBlockedRecord);
assertCondition(remoteBlockedControlPlane.transport === "remote-frontend", "remote infra-blocked should keep remote frontend transport", remoteBlockedControlPlane);
assertCondition(!remoteBlockedScopes.includes("local-docker-control-plane"), "remote infra-blocked must not blame runner local Docker", remoteBlockedRecord);
assertCondition(remoteBlockedScopes.includes("registry-container") || remoteBlockedScopes.includes("registry-api") || remoteBlockedScopes.includes("provider-ssh-command"), "remote infra-blocked should expose registry/provider scope", remoteBlockedRecord);
assertCondition(String(remoteBlockedRecord.recommendedAction ?? "").includes("registry"), "remote infra-blocked should recommend registry recovery", remoteBlockedRecord);
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"runner backend-core dry-run auto-selects the remote frontend control plane",
"local-docker-required fallback is explicit and exposes a backend-core remote command shape",
"remote ready preflight reports D601/registry/artifact requirements without starting CI",
"remote infra-blocked preflight does not misclassify runner-local Docker absence",
"backend-core dry-run exposes target commit, source repo, D601 runner and registry target",
"backend-core dry-run reports wouldBuildOnD601 without starting a build",
"artifact labels and digest requirements are visible without faking a digest",
"standard path is publish artifact, verify labels/digest, then dev pull-only apply",
],
localBlockedScopes,
readyBlockedScopes: record.blockedScopes,
remoteBlockedScopes,
}, null, 2)}\n`);
}
+303 -28
View File
@@ -87,7 +87,7 @@ interface DispatchResult {
raw: unknown;
}
type PublishPreflightFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required" | "registry-not-installed" | "registry-unhealthy" | "remote-command-timeout" | "ssh-helper-command-shape-incompatible";
type PublishPreflightFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required" | "registry-not-installed" | "registry-unhealthy" | "remote-command-timeout" | "ssh-helper-command-shape-incompatible" | "ci-runner-not-ready";
type PublishPreflightControlChannel = "backend-core" | "database" | "provider" | "registry";
type PublishPreflightDetailedChannel = "backend-core-api" | "provider-dispatch" | "provider-host-ssh" | "database" | "artifact-registry";
@@ -463,6 +463,7 @@ function recommendedPublishPreflightAction(
if (failureClassification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats";
if (failureClassification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health";
if (failureClassification === "registry-unhealthy") return "restore the D601 artifact registry service/container/API until artifact-registry health passes";
if (failureClassification === "ci-runner-not-ready") return "restore D601 unidesk-ci Tekton runner prerequisites before authorizing backend-core artifact publication";
if (missingControlChannels.includes("registry")) return "restore or install the D601 artifact registry until artifact-registry health passes";
return `restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}`;
}
@@ -470,6 +471,7 @@ function recommendedPublishPreflightAction(
function publishPreflightControlPlane(
transport: PublishPreflightTransport,
failureClassification: PublishPreflightFailureClassification | null,
remoteCommandShape = "bun scripts/cli.ts --main-server-ip <host> ci publish-user-service --service <id> --commit <full-sha> --dry-run",
): Record<string, unknown> {
const kind = transportKind(transport);
const remoteCapable = kind !== "local-docker";
@@ -481,7 +483,7 @@ function publishPreflightControlPlane(
failureClassification,
preferredRunnerPath: "frontend-private-proxy",
fallbackRunnerPath: "main-server-local-docker",
remoteCommandShape: "bun scripts/cli.ts --main-server-ip <host> ci publish-user-service --service <id> --commit <full-sha> --dry-run",
remoteCommandShape,
providerTunnel: {
providerId: d601ProviderId,
command: "host.ssh",
@@ -497,6 +499,84 @@ function publishPreflightFailedScopes(preflight: PublishPreflight): string[] {
return preflight.missingChannels;
}
function ciRunnerPreflightScript(sourceHostPath: string): string {
return [
"set -euo pipefail",
`export KUBECONFIG=${shellQuote(d601Kubeconfig)}`,
"printf 'provider_host_ssh=ok\\n'",
"printf 'kubectl='",
"command -v kubectl >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }",
"printf 'docker='",
"command -v docker >/dev/null && printf 'ok\\n' || { printf 'missing\\n'; exit 127; }",
"printf 'docker_socket='",
"if [ -S /var/run/docker.sock ] || [ -S /run/docker.sock ]; then printf 'true\\n'; else printf 'false\\n'; fi",
"printf 'namespace='",
"kubectl get namespace unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'tekton_pipeline='",
"kubectl get pipeline/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'tekton_task='",
"kubectl get task/unidesk-backend-core-artifact-publish -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'service_account='",
"kubectl get serviceaccount/unidesk-ci-runner -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'pvc='",
"kubectl get pvc/unidesk-ci-cache -n unidesk-ci >/dev/null 2>&1 && printf 'true\\n' || printf 'false\\n'",
"printf 'source_parent_directory='",
`test -d ${shellQuote(posixPath.dirname(sourceHostPath))} && printf 'true\\n' || printf 'false\\n'`,
].join("\n");
}
function keyValueBool(stdout: string, key: string): boolean {
const match = new RegExp(`^${key}=(.*)$`, "mu").exec(stdout);
if (match === null) return false;
const value = match[1]?.trim().toLowerCase() ?? "";
return value === "true" || value === "ok";
}
function backendCoreCiRunnerReady(result: DispatchResult): boolean {
return result.ok
&& keyValueBool(result.stdout, "kubectl")
&& keyValueBool(result.stdout, "docker")
&& keyValueBool(result.stdout, "namespace")
&& keyValueBool(result.stdout, "tekton_pipeline")
&& keyValueBool(result.stdout, "tekton_task")
&& keyValueBool(result.stdout, "service_account")
&& keyValueBool(result.stdout, "pvc")
&& keyValueBool(result.stdout, "source_parent_directory");
}
function backendCoreArtifactRequirements(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record<string, unknown> {
return {
requiredLabels: {
"unidesk.ai/service-id": "backend-core",
"unidesk.ai/source-commit": options.commit,
"unidesk.ai/source-repo": options.repoUrl,
"unidesk.ai/dockerfile": options.dockerfile,
},
digest: {
requiredAfterPublish: true,
dryRunValue: plannedArtifact.digest,
fields: ["artifactSummary.digest", "artifactSummary.digestRef"],
source: "D601 registry manifest HEAD after ci publish-backend-core succeeds",
},
imageRef: plannedArtifact.imageRef,
};
}
function backendCoreDevApplyPath(options: CiPublishBackendCoreOptions, plannedArtifact: ArtifactSummary): Record<string, unknown> {
return {
environment: "dev",
pullOnly: true,
apply: `bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`,
sourceImage: plannedArtifact.imageRef,
forbidden: ["--env prod", "cargo build", "docker build", "docker compose build", "server rebuild backend-core"],
note: "PROD is intentionally not part of backend-core publish preflight; dev apply still needs explicit authorization after artifact publication.",
};
}
function backendCoreRemoteCommandShape(commit: string): string {
return `bun scripts/cli.ts --main-server-ip <host> ci publish-backend-core --commit ${commit} --dry-run`;
}
function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult {
return {
ok: false,
@@ -1499,6 +1579,103 @@ async function publishUserServicePreflight(
};
}
async function publishBackendCorePreflight(
_config: UniDeskConfig,
options: CiPublishBackendCoreOptions,
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", "backend-core", 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", "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 ciScript = ciRunnerPreflightScript(options.sourceHostPath);
const ciProbe = await transport.dispatchHostSsh(ciScript, 30_000, 15_000);
const ciRunnerReady = backendCoreCiRunnerReady(ciProbe);
channels.push(channelProbe("provider-dispatch", "provider", ciProbe.taskId !== null || ciProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", {
taskId: ciProbe.taskId,
status: ciProbe.status,
ok: ciProbe.taskId !== null || ciProbe.ok,
exitCode: ciProbe.exitCode,
stderrTail: ciProbe.stderr.slice(-1200),
}));
channels.push(channelProbe("provider-host-ssh", "provider", ciRunnerReady, "D601 host.ssh can observe Docker, kubectl, Tekton, PVC, and backend-core source parent without starting CI", {
taskId: ciProbe.taskId,
status: ciProbe.status,
exitCode: ciProbe.exitCode,
stdoutTail: ciProbe.stdout.slice(-1600),
stderrTail: ciProbe.stderr.slice(-1200),
raw: ciRunnerReady ? undefined : dispatchPreflightFailure("backend-core ci runner readonly probe", ciProbe).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;
channels.push(channelProbe("artifact-registry", "registry", registryOk, "backend-core commit-pinned image push and later dev CD manifest checks", registry));
const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel);
const controlChannels = summarizePublishControlChannels(channels);
const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel);
const ready = missingChannels.length === 0;
const baseFailureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, ciProbe, registry);
const failureClassification = !ready && baseFailureClassification === null ? "ci-runner-not-ready" : baseFailureClassification;
const recommendedAction = ready
? `authorize and run: bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`
: recommendedPublishPreflightAction(failureClassification, registry, missingControlChannels);
return {
ok: ready,
runnerDisposition: ready ? "ready" : "infra-blocked",
failureClassification,
serviceId: "backend-core",
commit: options.commit,
providerId,
supportedArtifactPublish: true,
missingChannels,
missingControlChannels,
controlChannels,
channels,
registry,
controlPlane: publishPreflightControlPlane(transport, failureClassification, backendCoreRemoteCommandShape(options.commit)),
recommendedAction,
remoteCommandShape: registryProbe.remoteCommandShape,
next: ready
? [
`bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
`verify ${plannedArtifact.imageRef} labels and digest before any dev artifact consumer apply`,
`dev-only follow-up: bun scripts/cli.ts deploy apply --env dev --service backend-core --commit ${options.commit}`,
]
: [
`Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`,
failureClassification === "local-docker-required"
? `From a Code Queue runner, rerun this read-only preflight through the remote frontend transport: ${backendCoreRemoteCommandShape(options.commit)}.`
: "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/Tekton/registry readiness before retrying backend-core artifact publication.",
"This preflight must remain read-only; do not start Tekton, compile Rust, push registry artifacts, deploy/apply, or restart services.",
],
boundary: "preflight is read-only: no Tekton PipelineRun, no Rust compile, no source export, no image push, no registry write, no deploy apply, no service restart",
};
}
async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
@@ -1583,32 +1760,13 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
if (options.dryRun) {
return {
ok: true,
mode: "dry-run",
pipeline: "unidesk-backend-core-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
sourceHostPath: options.sourceHostPath,
source: {
ok: true,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: repoSshUrl(options.repoUrl),
commit: options.commit,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
},
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-backend-core --commit ${options.commit} --wait-ms 1200000`,
],
};
return runCiPublishBackendCoreDryRunPreflight(config, [
"publish-backend-core",
"--commit",
options.commit,
"--dry-run",
...(options.waitMs > 0 ? ["--wait-ms", String(options.waitMs)] : []),
], localPublishPreflightTransport, options);
}
const source = await prepareBackendCoreArtifactSource(config, options);
const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options));
@@ -1836,6 +1994,123 @@ export async function runCiPublishUserServiceDryRunPreflight(
};
}
export async function runCiPublishBackendCoreDryRunPreflight(
config: UniDeskConfig,
args: string[],
transport: PublishPreflightTransport,
resolvedOptions?: CiPublishBackendCoreOptions,
): Promise<Record<string, unknown>> {
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
if (!args.includes("--dry-run")) throw new Error("publish-backend-core preflight requires --dry-run");
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-backend-core reads source repo from CI.json; edit CI.json instead of using --repo");
}
const artifact = resolveCatalogArtifact("backend-core");
if (artifact.kind !== "source-build") throw new Error("backend-core must be modeled as a source-build artifact in CI.json");
if (artifact.status === "blocked") return blockedArtifactResult(artifact, commit, blockedReason(artifact));
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, "CI.json.artifacts.backend-core.source.dockerfile");
const options: CiPublishBackendCoreOptions = resolvedOptions ?? {
repoUrl: artifact.source.repo,
commit,
waitMs: numberOption(args, "--wait-ms", 0),
sourceHostPath: backendCoreArtifactSourceHostPath(commit),
dockerfile,
imageRepository: artifact.image.repository,
dryRun: true,
};
const summaryContext: ArtifactSummaryContext = {
serviceId: "backend-core",
commit,
repoUrl: options.repoUrl,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const preflight = await publishBackendCorePreflight(config, options, plannedArtifact, transport);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
const blockedScopes = publishPreflightFailedScopes(preflight);
return {
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-backend-core-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
targetCommit: options.commit,
sourceRepo: options.repoUrl,
serviceId: "backend-core",
providerId: d601ProviderId,
ciRunner: {
providerId: d601ProviderId,
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-backend-core-artifact-publish",
wouldBuildOnD601: true,
realBuildRequiresAuthorization: true,
},
registryTarget: plannedArtifact.repository,
wouldBuildOnD601: true,
dryRunBuildStarted: false,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
missingControlChannels: preflight.missingControlChannels,
controlChannels: preflight.controlChannels,
channels: preflight.channels,
failureClassification: preflight.failureClassification,
failedScopes: blockedScopes,
blockedScopes,
recommendedAction: preflight.recommendedAction,
remoteCommandShape: preflight.remoteCommandShape,
remoteControlPlaneCandidate: preflight.controlPlane,
controlPlane: preflight.controlPlane,
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: "backend-core",
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
},
sourceAuth: {
repoFetchUrl: plannedRepoFetchUrl,
egressProxy: providerGatewayWsEgressProxyUrl,
gitSshProxy: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
identityRequired: repoNeedsGithubSshIdentity(plannedRepoFetchUrl),
},
d601Ci: {
providerId: d601ProviderId,
namespace: "unidesk-ci",
pipeline: "unidesk-backend-core-artifact-publish",
wouldBuildOnD601: true,
dryRunWillStartTekton: false,
dryRunWillCompileRust: false,
dryRunWillPushRegistry: false,
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
artifactRequirements: backendCoreArtifactRequirements(options, plannedArtifact),
controlledPublish: {
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-backend-core-artifact-publish",
command: `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`,
requiresReadyControlChannels: publishPreflightControlChannelOrder,
requiresHumanAuthorization: true,
},
devApplyPath: backendCoreDevApplyPath(options, 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;
+32 -29
View File
@@ -11,7 +11,7 @@ import {
buildArtifactRegistryReadonlyProbe,
parseArtifactRegistryOptions,
} from "./artifact-registry";
import { runCiPublishUserServiceDryRunPreflight } from "./ci";
import { runCiPublishBackendCoreDryRunPreflight, runCiPublishUserServiceDryRunPreflight } from "./ci";
export interface RemoteCliOptions {
host: string | null;
@@ -109,9 +109,9 @@ export function autoRemoteCiPublishUserServiceDryRunPlan(
env: NodeJS.ProcessEnv = process.env,
): AutoRemoteCiPublishPlan {
const [top, sub] = args;
const isPublishDryRun = top === "ci" && sub === "publish-user-service" && args.includes("--dry-run");
const isPublishDryRun = top === "ci" && (sub === "publish-user-service" || sub === "publish-backend-core") && args.includes("--dry-run");
if (!isPublishDryRun) {
return { enabled: false, host: null, reason: "not ci publish-user-service --dry-run", failureClassification: null, transport: "frontend", command: null };
return { enabled: false, host: null, reason: "not ci publish-user-service/publish-backend-core --dry-run", failureClassification: null, transport: "frontend", command: null };
}
if (truthyDisabled(env.UNIDESK_CI_PUBLISH_AUTO_REMOTE)) {
return { enabled: false, host: null, reason: "UNIDESK_CI_PUBLISH_AUTO_REMOTE disables automatic remote preflight", failureClassification: "local-docker-required", transport: "frontend", command: null };
@@ -721,36 +721,39 @@ async function remoteArtifactRegistry(session: FrontendSession, args: string[]):
async function remoteCi(session: FrontendSession, config: UniDeskConfig, args: string[]): Promise<unknown> {
const action = args[1] ?? "status";
if (action !== "publish-user-service" || !args.includes("--dry-run")) {
throw new Error("remote frontend transport supports only ci publish-user-service --dry-run preflight; real CI publication must run from the controlled main-server CLI after preflight is ready");
if ((action !== "publish-user-service" && action !== "publish-backend-core") || !args.includes("--dry-run")) {
throw new Error("remote frontend transport supports only ci publish-user-service --dry-run and ci publish-backend-core --dry-run preflight; real CI publication must run from the controlled main-server CLI after preflight is ready");
}
const transport = {
kind: "remote-frontend" as const,
remoteHost: session.baseUrl,
coreFetch: (path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }) => frontendJson(session, path, init === undefined ? undefined : {
method: init.method,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
}, 12_000, init?.maxResponseBytes ?? 500_000),
dispatchHostSsh: async (command: string, waitMs: number, remoteTimeoutMs: number) => {
const dispatched = await dispatchHostSshJson(session, "D601", command, remoteTimeoutMs, waitMs);
const task = dispatched.task;
const result = task?.result ?? {};
return {
ok: task?.status === "succeeded" && (typeof result.exitCode !== "number" || result.exitCode === 0) && dispatched.taskId !== null,
taskId: dispatched.taskId,
status: task?.status ?? null,
stdout: typeof result.stdout === "string" ? result.stdout : "",
stderr: typeof result.stderr === "string" ? result.stderr : "",
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
raw: task ?? dispatched.wait ?? dispatched.dispatch,
};
},
commandCwd: ".",
artifactRegistryCommand: (probe: ReturnType<typeof buildArtifactRegistryReadonlyProbe>) => ["frontend", "/api/dispatch", probe.providerId, "host.ssh", probe.action, dispatchedTaskShape(probe.remoteCommandShape)],
};
return {
transport: "frontend",
readonly: true,
result: await runCiPublishUserServiceDryRunPreflight(config, args.slice(1), {
kind: "remote-frontend",
remoteHost: session.baseUrl,
coreFetch: (path, init) => frontendJson(session, path, init === undefined ? undefined : {
method: init.method,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
}, 12_000, init?.maxResponseBytes ?? 500_000),
dispatchHostSsh: async (command, waitMs, remoteTimeoutMs) => {
const dispatched = await dispatchHostSshJson(session, "D601", command, remoteTimeoutMs, waitMs);
const task = dispatched.task;
const result = task?.result ?? {};
return {
ok: task?.status === "succeeded" && (typeof result.exitCode !== "number" || result.exitCode === 0) && dispatched.taskId !== null,
taskId: dispatched.taskId,
status: task?.status ?? null,
stdout: typeof result.stdout === "string" ? result.stdout : "",
stderr: typeof result.stderr === "string" ? result.stderr : "",
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
raw: task ?? dispatched.wait ?? dispatched.dispatch,
};
},
commandCwd: ".",
artifactRegistryCommand: (probe) => ["frontend", "/api/dispatch", probe.providerId, "host.ssh", probe.action, dispatchedTaskShape(probe.remoteCommandShape)],
}),
result: action === "publish-backend-core"
? await runCiPublishBackendCoreDryRunPreflight(config, args.slice(1), transport)
: await runCiPublishUserServiceDryRunPreflight(config, args.slice(1), transport),
};
}