From 9977f006214c4edca2764f4c55dccc5bd129cc23 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 09:00:58 +0000 Subject: [PATCH] fix: route publish dry-run through remote control plane --- ...sh-user-service-preflight-contract-test.ts | 37 +++++ scripts/cli.ts | 12 +- scripts/src/ci.ts | 67 ++++++++- scripts/src/remote.ts | 140 +++++++++++++++++- 4 files changed, 246 insertions(+), 10 deletions(-) diff --git a/scripts/ci-publish-user-service-preflight-contract-test.ts b/scripts/ci-publish-user-service-preflight-contract-test.ts index 509f1ca8..8444a4fc 100644 --- a/scripts/ci-publish-user-service-preflight-contract-test.ts +++ b/scripts/ci-publish-user-service-preflight-contract-test.ts @@ -1,5 +1,6 @@ import { readConfig } from "./src/config"; import { runCiPublishUserServiceDryRunPreflight, type PublishPreflightTransport } from "./src/ci"; +import { autoRemoteCiPublishUserServiceDryRunPlan } from "./src/remote"; type JsonRecord = Record; @@ -46,6 +47,35 @@ const infraBlockedTransport: PublishPreflightTransport = { }; async function main(): Promise { + const autoRemote = autoRemoteCiPublishUserServiceDryRunPlan(config, [ + "ci", + "publish-user-service", + "--service", + "frontend", + "--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 publish dry-run should auto-select remote frontend transport", autoRemote); + assertCondition(autoRemote.host === "74.48.78.17", "auto remote plan should use CODE_QUEUE_DEV_CONTAINER_MASTER_HOST", autoRemote); + assertCondition(autoRemote.transport === "frontend", "auto remote plan should use frontend transport", autoRemote); + assertCondition(String(autoRemote.command ?? "").includes("--main-server-ip 74.48.78.17"), "auto remote plan should expose command shape", autoRemote); + + const nonRunner = autoRemoteCiPublishUserServiceDryRunPlan(config, [ + "ci", + "publish-user-service", + "--service", + "frontend", + "--commit", + commit, + "--dry-run", + ], {}); + assertCondition(nonRunner.enabled === false, "non-runner local CLI should not auto-remote without a host hint", nonRunner); + assertCondition(nonRunner.failureClassification === "local-docker-required", "non-runner disabled plan should classify local docker requirement", nonRunner); + const result = await runCiPublishUserServiceDryRunPreflight(config, [ "publish-user-service", "--service", @@ -61,6 +91,7 @@ async function main(): Promise { const controlChannels = Array.isArray(record.controlChannels) ? record.controlChannels.map((item) => asRecord(item, "control channel")) : []; const registry = asRecord(record.registry, "registry"); const controlledPublish = asRecord(record.controlledPublish, "controlledPublish"); + const controlPlane = asRecord(record.controlPlane, "controlPlane"); const backendCore = asRecord(channels.find((item) => item.channel === "backend-core-api")?.detail, "backendCore detail"); const backendCoreTransport = asRecord(backendCore.detail, "backendCore transport payload"); const backendCoreBody = asRecord(backendCoreTransport.body, "backendCore body payload"); @@ -71,6 +102,10 @@ async function main(): Promise { assertCondition(record.ok === false, "infra-blocked preflight should fail", record); assertCondition(record.mode === "dry-run-preflight", "dry-run preflight mode should be reported", record); assertCondition(record.runnerDisposition === "infra-blocked", "runnerDisposition should be infra-blocked", record); + assertCondition(record.failureClassification === "local-docker-required", "local backend-core absence should classify as local-docker-required", record); + assertCondition(controlPlane.transport === "local-docker", "local preflight should expose local-docker transport", controlPlane); + assertCondition(controlPlane.remoteCapable === false, "local preflight should not claim remote-capable transport", controlPlane); + assertCondition(controlPlane.preferredRunnerPath === "frontend-private-proxy", "controlPlane should name frontend private proxy as preferred path", controlPlane); assertCondition(Array.isArray(record.missingChannels), "missingChannels should be an array", record); assertCondition(Array.isArray(record.missingControlChannels), "missingControlChannels should be an array", record); assertCondition(missingChannels.includes("backend-core-api"), "backend-core-api should be missing", record); @@ -108,6 +143,8 @@ async function main(): Promise { "artifact summary remains commit-pinned and read-only", "missingControlChannels maps detailed probes to backend-core/database/provider/registry", "controlledPublish identifies D601 unidesk-ci as the only place for the real publish", + "runner-like environments auto-select the existing frontend remote transport", + "local backend-core absence is classified as local-docker-required", ], missingChannels: record.missingChannels, missingControlChannels: record.missingControlChannels, diff --git a/scripts/cli.ts b/scripts/cli.ts index 8ccdd5af..a25bda1a 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -6,7 +6,7 @@ import { emitError, emitJson } from "./src/output"; import { jobWithTail, listJobs, listJobsSummary, readJob, runJob } from "./src/jobs"; import { checkHelp, parseCheckOptions, runChecks } from "./src/check"; import { runSsh } from "./src/ssh"; -import { extractRemoteCliOptions, runRemoteCli } from "./src/remote"; +import { autoRemoteCiPublishUserServiceDryRunPlan, extractRemoteCliOptions, runRemoteCli } from "./src/remote"; import { runMicroserviceCommand } from "./src/microservices"; import { runCodeQueueCommand } from "./src/code-queue"; import { runDecisionCenterCommand } from "./src/decision-center"; @@ -199,6 +199,16 @@ async function main(): Promise { } const config = readConfig(); + const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args); + if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) { + process.exitCode = await runRemoteCli({ + ...remoteOptions, + host: autoRemoteCiPublishPlan.host, + transport: "frontend", + args, + }, config); + return; + } if (top === "ssh") { const exitCode = await runSsh(config, sub ?? "", args.slice(2)); diff --git a/scripts/src/ci.ts b/scripts/src/ci.ts index 9fd6e857..45aa78c7 100644 --- a/scripts/src/ci.ts +++ b/scripts/src/ci.ts @@ -87,6 +87,7 @@ interface DispatchResult { raw: unknown; } +type PublishPreflightFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required"; type PublishPreflightControlChannel = "backend-core" | "database" | "provider" | "registry"; type PublishPreflightDetailedChannel = "backend-core-api" | "provider-dispatch" | "provider-host-ssh" | "database" | "artifact-registry"; @@ -108,6 +109,7 @@ interface PublishPreflightControlChannelProbe { interface PublishPreflight { ok: boolean; runnerDisposition: "ready" | "infra-blocked"; + failureClassification: PublishPreflightFailureClassification | null; serviceId: string; commit: string; providerId: string; @@ -117,11 +119,14 @@ interface PublishPreflight { controlChannels: PublishPreflightControlChannelProbe[]; channels: PublishPreflightChannelProbe[]; registry: unknown; + controlPlane: Record; next: string[]; boundary: string; } export interface PublishPreflightTransport { + kind?: "local-docker" | "remote-frontend" | "provider-tunnel"; + remoteHost?: string | null; coreFetch: (path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }) => unknown | Promise; dispatchHostSsh: (command: string, waitMs: number, remoteTimeoutMs: number) => Promise; commandCwd: string; @@ -400,13 +405,63 @@ function summarizePublishControlChannels(channels: PublishPreflightChannelProbe[ function backendCoreUnavailable(value: unknown): boolean { const record = asRecord(value); + const body = coreBody(value); if (record?.runnerDisposition === "infra-blocked") return true; if (record?.failureKind === "target-stack-not-running") return true; + if (body?.runnerDisposition === "infra-blocked") return true; + if (body?.failureKind === "target-stack-not-running") return true; + if (body?.degradedReason === "backend-core-container-missing") return true; const text = JSON.stringify(value) ?? ""; return text.includes("No such container: unidesk-backend-core") || text.includes("No such container: unidesk-database"); } +function transportKind(transport: PublishPreflightTransport): "local-docker" | "remote-frontend" | "provider-tunnel" { + return transport.kind ?? "local-docker"; +} + +function classifyPublishPreflightFailure( + transport: PublishPreflightTransport, + overview: unknown, + sshProbe: DispatchResult, + registry: unknown, +): PublishPreflightFailureClassification | null { + const kind = transportKind(transport); + const overviewRecord = asRecord(overview); + if (overviewRecord?.failureClassification === "auth-missing") return "auth-missing"; + if (overviewRecord?.failureClassification === "remote-proxy-missing") return "remote-proxy-missing"; + if (overviewRecord?.failureClassification === "provider-unreachable") return "provider-unreachable"; + if (kind === "local-docker" && backendCoreUnavailable(overview)) return "local-docker-required"; + if (kind !== "local-docker" && !responseOk(overview)) return "remote-proxy-missing"; + if (!sshProbe.ok) return "provider-unreachable"; + const registryRecord = asRecord(registry); + if (registryRecord?.ok === false) return "provider-unreachable"; + return null; +} + +function publishPreflightControlPlane( + transport: PublishPreflightTransport, + failureClassification: PublishPreflightFailureClassification | null, +): Record { + const kind = transportKind(transport); + const remoteCapable = kind !== "local-docker"; + return { + transport: kind, + remoteCapable, + remoteHost: transport.remoteHost ?? null, + localDockerRequired: kind === "local-docker", + 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", + providerTunnel: { + providerId: d601ProviderId, + command: "host.ssh", + requiredFor: ["provider-host-ssh", "artifact-registry"], + }, + }; +} + function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult { return { ok: false, @@ -468,6 +523,7 @@ function artifactRegistryProbeCommand(probe: ArtifactRegistryReadonlyProbe): str } const localPublishPreflightTransport: PublishPreflightTransport = { + kind: "local-docker", coreFetch: (path, init) => coreInternalFetch(path, init), dispatchHostSsh: dispatchReadonlySsh, commandCwd: repoRoot, @@ -1373,9 +1429,11 @@ async function publishUserServicePreflight( const controlChannels = summarizePublishControlChannels(channels); const missingControlChannels = controlChannels.filter((item) => !item.ok).map((item) => item.channel); const ready = missingChannels.length === 0; + const failureClassification = ready ? null : classifyPublishPreflightFailure(transport, overview, sshProbe, registry); return { ok: ready, runnerDisposition: ready ? "ready" : "infra-blocked", + failureClassification, serviceId: options.serviceId, commit: options.commit, providerId, @@ -1385,6 +1443,7 @@ async function publishUserServicePreflight( controlChannels, channels, registry, + controlPlane: publishPreflightControlPlane(transport, failureClassification), next: ready ? [ `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`, @@ -1392,7 +1451,9 @@ async function publishUserServicePreflight( ] : [ `Restore missing control channel(s): ${missingControlChannels.join(", ") || "unknown"}.`, - "Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.", + failureClassification === "local-docker-required" + ? "From a Code Queue runner, rerun this read-only preflight through the existing remote frontend transport: bun scripts/cli.ts --main-server-ip ci publish-user-service --service --commit --dry-run." + : "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.", ], @@ -1572,6 +1633,8 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl missingControlChannels: preflight.missingControlChannels, controlChannels: preflight.controlChannels, channels: preflight.channels, + failureClassification: preflight.failureClassification, + controlPlane: preflight.controlPlane, registry: preflight.registry, sourceHostPath: options.sourceHostPath, source: { @@ -1697,6 +1760,8 @@ export async function runCiPublishUserServiceDryRunPreflight( missingControlChannels: preflight.missingControlChannels, controlChannels: preflight.controlChannels, channels: preflight.channels, + failureClassification: preflight.failureClassification, + controlPlane: preflight.controlPlane, registry: preflight.registry, sourceHostPath: options.sourceHostPath, source: { diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 9d9a53eb..15417f79 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -23,6 +23,17 @@ export interface RemoteCliOptions { args: string[]; } +export type RemoteFailureClassification = "auth-missing" | "remote-proxy-missing" | "provider-unreachable" | "local-docker-required"; + +export interface AutoRemoteCiPublishPlan { + enabled: boolean; + host: string | null; + reason: string; + failureClassification: RemoteFailureClassification | null; + transport: "frontend"; + command: string | null; +} + interface FrontendSession { baseUrl: string; cookie: string; @@ -38,6 +49,19 @@ interface FetchJsonResult { responseContentLength?: string | null; } +class RemoteCliFailure extends Error { + readonly failureClassification: RemoteFailureClassification; + readonly runnerDisposition = "infra-blocked"; + readonly detail: unknown; + + constructor(failureClassification: RemoteFailureClassification, message: string, detail?: unknown) { + super(message); + this.name = "RemoteCliFailure"; + this.failureClassification = failureClassification; + this.detail = detail ?? null; + } +} + const hostOptions = new Set(["--main-server-ip", "--main-server", "--server"]); const userOptions = new Set(["--main-server-user", "--server-user"]); const portOptions = new Set(["--main-server-port", "--server-port"]); @@ -62,6 +86,55 @@ function transportValue(raw: string, option: string): RemoteCliOptions["transpor throw new Error(`${option} must be one of: auto, frontend, ssh`); } +function truthyDisabled(raw: string | undefined): boolean { + if (raw === undefined) return false; + const normalized = raw.trim().toLowerCase(); + return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "disabled"; +} + +function normalizeRemoteHostHint(raw: string | undefined): string | null { + const value = raw?.trim() ?? ""; + if (value.length === 0) return null; + if (value === "localhost" || value === "127.0.0.1" || value === "::1") return null; + return value.replace(/\/+$/u, ""); +} + +function isCodeQueueRunnerEnv(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.CODE_QUEUE_SERVICE_ROLE || env.CODE_QUEUE_INSTANCE_ID || env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST || env.KUBERNETES_SERVICE_HOST); +} + +export function autoRemoteCiPublishUserServiceDryRunPlan( + config: UniDeskConfig, + args: string[], + env: NodeJS.ProcessEnv = process.env, +): AutoRemoteCiPublishPlan { + const [top, sub] = args; + const isPublishDryRun = top === "ci" && sub === "publish-user-service" && 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 }; + } + 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 }; + } + const explicitHost = normalizeRemoteHostHint(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST) + ?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_IP) + ?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_HOST); + const host = explicitHost ?? (isCodeQueueRunnerEnv(env) ? normalizeRemoteHostHint(config.network.publicHost) : null); + if (host === null) { + return { enabled: false, host: null, reason: "no remote main-server frontend host was detected for this runner", failureClassification: "local-docker-required", transport: "frontend", command: null }; + } + return { + enabled: true, + host, + reason: explicitHost === null + ? "Code Queue runner environment detected; using config.network.publicHost as the frontend control-plane" + : "runner remote main-server frontend host detected", + failureClassification: null, + transport: "frontend", + command: ["bun", "scripts/cli.ts", "--main-server-ip", host, ...args].join(" "), + }; +} + export function extractRemoteCliOptions(argv: string[]): RemoteCliOptions { const rest: string[] = []; const options: RemoteCliOptions = { @@ -168,7 +241,45 @@ function emitRemoteError(command: string, error: unknown): void { const payload = error instanceof Error ? { name: error.name, message: error.message, stack: error.stack ?? null } : { message: String(error) }; - process.stdout.write(`${JSON.stringify({ ok: false, command, error: payload }, null, 2)}\n`); + const classification = error instanceof RemoteCliFailure + ? { + failureClassification: error.failureClassification, + runnerDisposition: error.runnerDisposition, + retryable: error.failureClassification !== "auth-missing", + detail: error.detail, + recoveryHints: remoteFailureRecoveryHints(error.failureClassification), + } + : null; + process.stdout.write(`${JSON.stringify({ + ok: false, + command, + ...(classification === null ? {} : classification), + error: payload, + }, null, 2)}\n`); +} + +function remoteFailureRecoveryHints(classification: RemoteFailureClassification): string[] { + if (classification === "auth-missing") { + return [ + "verify config.json frontend auth.username/auth.password against the remote main-server frontend login", + "retry the same command through --main-server-ip after credentials are restored", + ]; + } + if (classification === "remote-proxy-missing") { + return [ + "verify the remote frontend is reachable on the configured public frontend port", + "verify frontend can proxy /api to backend-core before retrying artifact publish preflight", + ]; + } + if (classification === "provider-unreachable") { + return [ + "verify backend-core /api/dispatch can create D601 host.ssh tasks", + "verify the D601 provider-gateway host.ssh capability and artifact registry health", + ]; + } + return [ + "run this read-only preflight with --main-server-ip from runner environments without local backend-core/database containers", + ]; } function commandName(args: string[]): string { @@ -238,17 +349,28 @@ async function readJson(url: string, init?: RequestInit, timeoutMs = 8000, maxRe async function loginFrontend(host: string, config: UniDeskConfig): Promise { const baseUrl = frontendBaseUrl(host, config); - const res = await fetch(`${baseUrl}/login`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ username: config.auth.username, password: config.auth.password }), - }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8_000); + let res: Response; + try { + res = await fetch(`${baseUrl}/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: config.auth.username, password: config.auth.password }), + signal: controller.signal, + }); + } catch (error) { + throw new RemoteCliFailure("remote-proxy-missing", `frontend login request failed via ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`, { baseUrl }); + } finally { + clearTimeout(timer); + } const body = await res.text(); if (!res.ok) { - throw new Error(`frontend login failed via ${baseUrl}: status=${res.status} body=${body.slice(0, 300)}`); + const failureClassification: RemoteFailureClassification = res.status === 401 || res.status === 403 ? "auth-missing" : "remote-proxy-missing"; + throw new RemoteCliFailure(failureClassification, `frontend login failed via ${baseUrl}: status=${res.status} body=${body.slice(0, 300)}`, { baseUrl, status: res.status }); } const cookie = res.headers.get("set-cookie")?.split(";")[0] ?? ""; - if (cookie.length === 0) throw new Error(`frontend login via ${baseUrl} did not return a session cookie`); + if (cookie.length === 0) throw new RemoteCliFailure("auth-missing", `frontend login via ${baseUrl} did not return a session cookie`, { baseUrl, status: res.status }); return { baseUrl, cookie }; } @@ -606,6 +728,8 @@ async function remoteCi(session: FrontendSession, config: UniDeskConfig, args: s 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),