From 1749897d1a698d77e0447c85789d95588f13671e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 10:08:56 +0000 Subject: [PATCH] fix: remoteize backend-core publish preflight --- ...sh-backend-core-preflight-contract-test.ts | 231 ++++++++++-- scripts/src/ci.ts | 331 ++++++++++++++++-- scripts/src/remote.ts | 61 ++-- 3 files changed, 535 insertions(+), 88 deletions(-) diff --git a/scripts/ci-publish-backend-core-preflight-contract-test.ts b/scripts/ci-publish-backend-core-preflight-contract-test.ts index 9bc0b744..8866ecca 100644 --- a/scripts/ci-publish-backend-core-preflight-contract-test.ts +++ b/scripts/ci-publish-backend-core-preflight-contract-test.ts @@ -1,5 +1,6 @@ import { readConfig } from "./src/config"; import { runCiPublishBackendCoreDryRunPreflight, type PublishPreflightTransport } from "./src/ci"; +import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote"; type JsonRecord = Record; @@ -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 { + 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 { 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 { 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`); } diff --git a/scripts/src/ci.ts b/scripts/src/ci.ts index ae906065..9a0019e9 100644 --- a/scripts/src/ci.ts +++ b/scripts/src/ci.ts @@ -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 ci publish-user-service --service --commit --dry-run", ): Record { 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 ci publish-user-service --service --commit --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 { + 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 { + 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 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 { + 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 { 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> { + 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 { const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000); const remoteTimeoutMs = 45_000; diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 9e37bdfe..66f7a3a6 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -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 { 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) => ["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), }; }