fix: route publish dry-run through remote control plane

This commit is contained in:
Codex
2026-05-21 09:00:58 +00:00
parent cf40cd5f6c
commit 9977f00621
4 changed files with 246 additions and 10 deletions
+132 -8
View File
@@ -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 <host> 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<FrontendSession> {
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),