feat: add artifact publish preflight

This commit is contained in:
Codex
2026-05-20 21:49:50 +00:00
parent 68ae2722ab
commit 021a9eef01
10 changed files with 636 additions and 45 deletions
+2
View File
@@ -126,6 +126,8 @@ bun scripts/cli.ts ssh D601 argv bash -lc '<readonly script>'
`status` 表示只读查询是否成功;未安装时仍可 `ok=true` 并报告 `installed=false``health` 表示 registry 是否已按期望运行;未安装或不健康时返回 `ok=false`。两个只读命令都应输出 `decision``retryable``healthyScopes``failedScopes``runtimeApiHealthy`,方便上层 provider triage 判断局部退化范围。
这两个只读命令也可以通过远端 frontend 透传调用,适合 runner 或指挥官在不登录主 server 本机 Docker 环境时做预检。若 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失,CLI 必须返回结构化 `infra-blocked` 和缺失通道,而不是让调用方只看到 Docker 的 `No such container`
registry health 的 `decision=service-degraded` 不等同于 D601 全局离线。特别是当 systemd unit inactive 或 unit hash drift,但 Docker container running、loopback listener 正常、`/v2/` 返回 200 时,runtime registry API 仍可用;这种状态应作为 registry 服务治理问题处理,不能覆盖 provider heartbeat、Host SSH、k3sctl-adapter、Code Queue scheduler 或业务 API 的健康证据。
## Manual Maintenance
+2
View File
@@ -124,6 +124,8 @@ The CI user-service artifact task must follow these rules:
- For D601 direct services, `findjob` and `pipeline` have reviewed dev/prod D601 Compose artifact consumers, `met-nonlinear` is dry-run only until the long-running service image contract matches the published artifact, and `k3sctl-adapter` is supervisor-only because it is the native k3s control bridge outside the k3s failure domain.
- ClaudeQQ source comes from `https://gitee.com/lyon1998/agent_skills`; the producer exports the `claudeqq/` subtree and overlays the UniDesk Dockerfile plus API adapter from `src/components/microservices/claudeqq/` before building. Runtime topology and deploy intent still live in manifests and `deploy.json`, not in `CI.json`.
The same command also has a read-only preflight mode: `bun scripts/cli.ts ci publish-user-service --service <id> --commit <full-sha> --dry-run`. That mode may be called from the main server or through remote frontend passthrough, and it must return `runnerDisposition`, `missingChannels`, `channels`, `registry`, `artifactSummary`, `boundary` and `next` without creating a PipelineRun or pushing an image. If backend-core, database or provider channels are missing, the result must be structured `infra-blocked`, not a bare container lookup failure.
Publish a Baidu Netdisk artifact:
```bash
+2
View File
@@ -187,3 +187,5 @@ backend-core and D601 `code-queue` remain restricted to dev image validation in
## Validation Boundary
This precheck uses lightweight parsing and dry-run evidence only. It intentionally does not run full `check`, e2e, Playwright, or other broad browser/runtime test suites on the master server because those are outside the precheck scope and may exceed master-server resource limits. `backend-core` and D601 `code-queue` production validation are also out of scope; backend-core dev rollout can be attempted only through the existing D601 dev path, and a provider-offline result is an infrastructure blocker rather than permission to validate production.
The structured read-only preflight entrypoints are `artifact-registry status|health` and `ci publish-user-service --dry-run`. Remote runners may call them through the frontend passthrough path, and the result must classify missing backend-core, database or provider channels as `runnerDisposition=infra-blocked` with explicit missing channel names. Those cases are infrastructure blockers, not business failures and not a license to retry a real publish.
+1 -1
View File
@@ -146,7 +146,7 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
默认 frontend 传输支持 `debug health``debug dispatch``debug task``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``decision requirement list/upsert``decision diary import/list/months/show/edit/upsert``codex task <taskId>``codex tasks``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>`。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname``ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py``ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
默认 frontend 传输支持 `debug health``debug dispatch``debug task``artifact-registry status|health``ci publish-user-service --dry-run``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``decision requirement list/upsert``decision diary import/list/months/show/edit/upsert``codex task <taskId>``codex tasks``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>`。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname``ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py``ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`若确实需要旧行为,可使用 `--main-server-key <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh``hostSshConfigured=true``hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000``bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
+2
View File
@@ -247,6 +247,8 @@ Code Queue task 不是只要 push 代码就算完成。
如果业务任务发现缺少工具或凭证路径,指挥官应把它拆成独立 infra task,而不是埋在业务任务 prompt 中。业务任务在 bridge 存在时应继续推进。
Artifact publish preflight 也属于基础设施问题的只读分类范畴:`artifact-registry status|health``ci publish-user-service --dry-run` 返回 `runnerDisposition=infra-blocked` 时,通常说明 backend-core/database/provider 通道缺失,而不是用户服务本身的业务错误。此时应先恢复控制通道,再决定是否重试,不要把裸 `No such container` 当成可直接回归的业务失败。
## 指挥边界
指挥官可以:
+2
View File
@@ -235,6 +235,8 @@ D601 默认 `kubectl` context 可能指向 Docker Desktop、kind 或其他本地
Continuous integration is intentionally separate from this deploy reconciler. D601 k3s hosts Tekton CI resources described in `docs/reference/ci.md`; PipelineRuns may clone, check, run read-only performance gates, create temporary CI-owned namespaces for dev manifest smoke e2e, or publish commit-pinned backend-core/user-service image artifacts to the D601 artifact registry. They must not call `deploy apply`, `codex deploy`, `kubectl rollout restart` for production services, mutate `deploy.json`, or write production namespaces.
Artifact publish preflight is part of CI, not deploy: `artifact-registry status|health` and `ci publish-user-service --dry-run` are the supported read-only checks for registry reachability and user-service publish readiness. These commands must not depend on a coincidentally present local `unidesk-database` container, and when backend-core/database/provider channels are missing they should return structured `infra-blocked` instead of a raw container error.
The Code Queue performance gate may create a temporary `code-queue-ci-read` service and read the main PostgreSQL through the existing `d601-tcp-egress-gateway`. Because it runs with `CODE_QUEUE_SERVICE_ROLE=read`, scheduler/backfill/notification disabled and EmptyDir state, it is not deployment truth and does not need a temporary database for the current read-only checks.
## Version Stamping And Verification
@@ -0,0 +1,101 @@
import { readConfig } from "./src/config";
import { runCiPublishUserServiceDryRunPreflight, type PublishPreflightTransport } from "./src/ci";
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function asRecord(value: unknown, label: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, { value });
return value as JsonRecord;
}
const commit = "0123456789abcdef0123456789abcdef01234567";
const config = readConfig();
const infraBlockedTransport: PublishPreflightTransport = {
coreFetch: async (path) => ({
ok: false,
status: 503,
body: {
ok: false,
failureKind: "target-stack-not-running",
runnerDisposition: "infra-blocked",
degradedReason: "backend-core-container-missing",
message: `backend-core unavailable for ${path}`,
},
}),
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) => [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script],
};
async function main(): Promise<void> {
const result = await runCiPublishUserServiceDryRunPreflight(config, [
"publish-user-service",
"--service",
"frontend",
"--commit",
commit,
"--dry-run",
], infraBlockedTransport);
const record = asRecord(result, "preflight");
const source = asRecord(record.source, "source");
const channels = Array.isArray(record.channels) ? record.channels.map((item) => asRecord(item, "channel")) : [];
const registry = asRecord(record.registry, "registry");
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");
const providerDispatch = asRecord(channels.find((item) => item.channel === "provider-dispatch")?.detail, "providerDispatch detail");
const missingChannels = Array.isArray(record.missingChannels) ? record.missingChannels as string[] : [];
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(Array.isArray(record.missingChannels), "missingChannels should be an array", record);
assertCondition(missingChannels.includes("backend-core-api"), "backend-core-api should be missing", record);
assertCondition(missingChannels.includes("database"), "database should be missing", record);
assertCondition(missingChannels.includes("provider-dispatch"), "provider-dispatch should be missing", record);
assertCondition(missingChannels.includes("provider-host-ssh"), "provider-host-ssh should be missing", record);
assertCondition(missingChannels.includes("artifact-registry"), "artifact-registry should be missing", record);
assertCondition(!JSON.stringify(record).includes("No such container: unidesk-database"), "raw container error should not leak", record);
assertCondition(backendCoreBody.failureKind === "target-stack-not-running", "backend-core detail should classify target-stack-not-running", backendCoreBody);
assertCondition(providerDispatch.status === "infra-blocked", "provider dispatch should be infra-blocked", providerDispatch);
assertCondition(registry.ok === false, "registry channel should fail without backend-core bridge", registry);
assertCondition(Array.isArray(channels) && channels.length >= 5, "expected five channel probes", channels);
assertCondition(source.mode === "planned-only", "source should remain planned-only", source);
assertCondition(source.repoFetchUrl === "git@github.com:pikasTech/unidesk.git", "source repo should use CI catalog ssh form", source);
assertCondition(asRecord(record.artifactSummary, "artifactSummary").imageRef === `127.0.0.1:5000/unidesk/frontend:${commit}`, "artifact ref should remain commit-pinned", record.artifactSummary);
assertCondition(String(record.boundary ?? "").includes("read-only"), "boundary should state preflight is read-only", record);
process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"dry-run preflight returns infra-blocked when backend-core/database/provider channels are absent",
"missing channel list names the absent channels",
"artifact summary remains commit-pinned and read-only",
],
missingChannels: record.missingChannels,
registry,
}, null, 2)}\n`);
}
if (import.meta.main) {
await main();
}
+60 -6
View File
@@ -5,10 +5,10 @@ import { runCommand, type CommandResult } from "./command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "./config";
import { startJob } from "./jobs";
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
export type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
type ArtifactDeployEnvironment = "prod" | "dev";
interface ArtifactRegistryOptions {
export interface ArtifactRegistryOptions {
environment: ArtifactDeployEnvironment | null;
providerId: string;
host: string;
@@ -49,6 +49,15 @@ interface RenderedBundle {
};
}
export interface ArtifactRegistryReadonlyProbe {
action: "status" | "health";
providerId: string;
script: string;
timeoutMs: number;
healthMode: boolean;
options: ArtifactRegistryOptions;
}
const defaultOptions: ArtifactRegistryOptions = {
environment: null,
providerId: "D601",
@@ -758,7 +767,7 @@ function environmentValue(value: string, option: string): ArtifactDeployEnvironm
throw new Error(`${option} must be one of: prod, dev`);
}
function parseOptions(args: string[]): ArtifactRegistryOptions {
export function parseArtifactRegistryOptions(args: string[]): ArtifactRegistryOptions {
const options = { ...defaultOptions };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
@@ -1103,7 +1112,7 @@ function registryHealthDecision(checks: Record<string, boolean>, commandOk: bool
};
}
function commandTail(result: CommandResult): Record<string, unknown> {
export function artifactRegistryCommandTail(result: CommandResult): Record<string, unknown> {
return {
command: result.command.length > 7 ? [...result.command.slice(0, 7), "<readonly-script>"] : result.command,
exitCode: result.exitCode,
@@ -1114,6 +1123,10 @@ function commandTail(result: CommandResult): Record<string, unknown> {
};
}
function commandTail(result: CommandResult): Record<string, unknown> {
return artifactRegistryCommandTail(result);
}
function artifactConsumerSpec(serviceId: string, environment: ArtifactDeployEnvironment | null): ArtifactConsumerSpec | null {
const key = environment === null || environment === "prod" ? serviceId : `${environment}:${serviceId}`;
const explicit = artifactConsumerSpecs[key];
@@ -1329,12 +1342,53 @@ function runReadonlyStatus(options: ArtifactRegistryOptions, healthMode: boolean
image: options.image,
paths: bundle.paths,
},
command: commandTail(result),
command: artifactRegistryCommandTail(result),
};
}
return statusFromValues(options, parseKeyValueOutput(result.stdout), result, healthMode);
}
export function buildArtifactRegistryReadonlyProbe(action: "status" | "health", options: ArtifactRegistryOptions): ArtifactRegistryReadonlyProbe {
const bundle = renderBundle(options);
const healthMode = action === "health";
return {
action,
providerId: options.providerId,
script: statusScript(options, bundle),
timeoutMs: options.timeoutMs,
healthMode,
options,
};
}
export function artifactRegistryReadonlyResultFromCommand(
probe: ArtifactRegistryReadonlyProbe,
command: CommandResult,
): Record<string, unknown> {
if (command.exitCode !== 0 || command.timedOut) {
const bundle = renderBundle(probe.options);
return {
ok: false,
readonly: true,
installed: false,
healthy: false,
decision: "retryable-transient",
retryable: true,
healthyScopes: [],
failedScopes: ["provider-ssh-command"],
runtimeApiHealthy: false,
checks: {},
expected: {
endpoint: `http://${probe.options.host}:${probe.options.port}`,
image: probe.options.image,
paths: bundle.paths,
},
command: artifactRegistryCommandTail(command),
};
}
return statusFromValues(probe.options, parseKeyValueOutput(command.stdout), command, probe.healthMode);
}
function remoteWriteFileCommand(item: RenderedFile): string {
const encoded = Buffer.from(item.content, "utf8").toString("base64");
const rootOwned = item.path.startsWith("/etc/");
@@ -2540,7 +2594,7 @@ export async function runArtifactRegistryCommand(args: string[]): Promise<unknow
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core|deploy-service");
}
const typedAction = action as ArtifactRegistryAction;
const options = parseOptions(args.slice(1));
const options = parseArtifactRegistryOptions(args.slice(1));
if (action === "plan") return plan(options);
if (action === "render") return { ok: true, providerId: options.providerId, render: renderBundle(options) };
if (action === "status") return runReadonlyStatus(options, false);
+342 -37
View File
@@ -7,6 +7,12 @@ import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "./deploy-ssh-identity";
import { startJob } from "./jobs";
import { coreInternalFetch } from "./microservices";
import {
artifactRegistryReadonlyResultFromCommand,
buildArtifactRegistryReadonlyProbe,
parseArtifactRegistryOptions,
type ArtifactRegistryReadonlyProbe,
} from "./artifact-registry";
const d601ProviderId = "D601";
const d601Kubeconfig = "/etc/rancher/k3s/k3s.yaml";
@@ -81,6 +87,34 @@ interface DispatchResult {
raw: unknown;
}
interface PublishPreflightChannelProbe {
channel: "backend-core-api" | "provider-dispatch" | "provider-host-ssh" | "database" | "artifact-registry";
ok: boolean;
requiredFor: string;
detail: unknown;
}
interface PublishPreflight {
ok: boolean;
runnerDisposition: "ready" | "infra-blocked";
serviceId: string;
commit: string;
providerId: string;
supportedArtifactPublish: boolean;
missingChannels: string[];
channels: PublishPreflightChannelProbe[];
registry: unknown;
next: string[];
boundary: string;
}
export interface PublishPreflightTransport {
coreFetch: (path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }) => unknown | Promise<unknown>;
dispatchHostSsh: (command: string, waitMs: number, remoteTimeoutMs: number) => Promise<DispatchResult>;
commandCwd: string;
artifactRegistryCommand: (probe: ArtifactRegistryReadonlyProbe) => string[];
}
interface PipelineRunCondition {
ok: boolean | null;
status: string;
@@ -262,6 +296,42 @@ function blockedReason(artifact: CiSourceBuildCatalogArtifact): string {
return artifact.blockedReason;
}
function userServicePublishBoundaryBlock(
config: UniDeskConfig,
serviceId: string,
commit: string,
artifact: CiSourceBuildCatalogArtifact,
): Record<string, unknown> | null {
const configService = config.microservices.find((item) => item.id === serviceId);
if (configService === undefined) return null;
const isD601K3sService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "k3sctl-managed";
const isD601DirectService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "unidesk-direct";
const isMainServerDirectService = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "internal-sidecar";
if (isD601K3sService || isD601DirectService || isMainServerDirectService || isMainServerInternalSidecar) return null;
return {
ok: false,
status: "blocked",
error: "blocked",
serviceId,
commit,
reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
catalogArtifact: artifact,
configService: {
providerId: configService.providerId,
deploymentMode: configService.deployment.mode,
},
};
}
function chunks(value: string, size: number): string[] {
const result: string[] = [];
for (let index = 0; index < value.length; index += size) {
@@ -282,6 +352,100 @@ function coreBody(response: unknown): Record<string, unknown> | null {
return asRecord(asRecord(response)?.body);
}
function responseOk(response: unknown): boolean {
if (typeof response !== "object" || response === null) return false;
const record = response as Record<string, unknown>;
if ("ok" in record && record.ok === false) return false;
const body = asRecord(record.body);
if (body !== null && "ok" in body && body.ok === false) return false;
return true;
}
function channelProbe(
channel: PublishPreflightChannelProbe["channel"],
ok: boolean,
requiredFor: string,
detail: unknown,
): PublishPreflightChannelProbe {
return { channel, ok, requiredFor, detail };
}
function backendCoreUnavailable(value: unknown): boolean {
const record = asRecord(value);
if (record?.runnerDisposition === "infra-blocked") return true;
if (record?.failureKind === "target-stack-not-running") return true;
const text = JSON.stringify(value) ?? "";
return text.includes("No such container: unidesk-backend-core")
|| text.includes("No such container: unidesk-database");
}
function dispatchPreflightFailure(command: string, result: DispatchResult): DispatchResult {
return {
ok: false,
taskId: result.taskId,
status: result.status,
stdout: result.stdout.slice(-4000),
stderr: result.stderr.slice(-4000),
exitCode: result.exitCode,
raw: {
command,
taskId: result.taskId,
status: result.status,
exitCode: result.exitCode,
stderrTail: result.stderr.slice(-1200),
stdoutTail: result.stdout.slice(-1200),
raw: result.raw,
},
};
}
function commandResultFromDispatch(command: string[], cwd: string, result: DispatchResult) {
return {
command,
cwd,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
signal: null,
timedOut: result.status === "timeout",
};
}
async function dispatchReadonlySsh(command: string, waitMs: number, remoteTimeoutMs: number): Promise<DispatchResult> {
try {
const result = await dispatchSsh(command, waitMs, remoteTimeoutMs);
if (!result.ok && backendCoreUnavailable(result.raw)) {
return {
...result,
status: "infra-blocked",
stderr: "backend-core bridge unavailable while dispatching readonly SSH task",
};
}
return result;
} catch (error) {
return {
ok: false,
taskId: null,
status: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
exitCode: null,
raw: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack ?? null } : String(error),
};
}
}
function artifactRegistryProbeCommand(probe: ArtifactRegistryReadonlyProbe): string[] {
return [process.execPath, "scripts/cli.ts", "ssh", probe.providerId, "argv", "bash", "-lc", probe.script];
}
const localPublishPreflightTransport: PublishPreflightTransport = {
coreFetch: (path, init) => coreInternalFetch(path, init),
dispatchHostSsh: dispatchReadonlySsh,
commandCwd: repoRoot,
artifactRegistryCommand: artifactRegistryProbeCommand,
};
function positiveManifestNumber(value: unknown, fallback: number, path: string): number {
if (value === undefined || value === null) return fallback;
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer`);
@@ -1117,6 +1281,92 @@ function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: s
}
}
async function publishUserServicePreflight(
_config: UniDeskConfig,
options: CiPublishUserServiceArtifactOptions,
plannedArtifact: ArtifactSummary,
transport: PublishPreflightTransport,
): Promise<PublishPreflight> {
const providerId = d601ProviderId;
const channels: PublishPreflightChannelProbe[] = [];
const overview = await transport.coreFetch("/api/overview", { maxResponseBytes: 500_000 });
const overviewBody = coreBody(overview);
const backendCoreOk = responseOk(overview) && overviewBody?.dbReady === true;
channels.push(channelProbe("backend-core-api", backendCoreOk, "dispatch API, provider catalog, task polling, and database-backed CI state", {
ok: responseOk(overview),
dbReady: overviewBody?.dbReady ?? null,
runnerDisposition: asRecord(overview)?.runnerDisposition ?? null,
failureKind: asRecord(overview)?.failureKind ?? null,
detail: backendCoreUnavailable(overview) ? overview : {
status: asRecord(overview)?.status ?? null,
body: overviewBody,
},
}));
channels.push(channelProbe("database", backendCoreOk, "backend-core task dispatch, provider state, Tekton task polling, and source identity lookup", {
dbReady: overviewBody?.dbReady ?? false,
observedThrough: "backend-core /api/overview",
}));
const probeScript = [
"set -euo pipefail",
"printf 'provider_host_ssh=ok\\n'",
"command -v bash >/dev/null",
"command -v docker >/dev/null",
"command -v kubectl >/dev/null",
"test -S /var/run/docker.sock || test -S /run/docker.sock || true",
].join("\n");
const sshProbe = await transport.dispatchHostSsh(probeScript, 30_000, 15_000);
channels.push(channelProbe("provider-dispatch", sshProbe.taskId !== null || sshProbe.ok, "backend-core /api/dispatch can create D601 host.ssh tasks", {
taskId: sshProbe.taskId,
status: sshProbe.status,
ok: sshProbe.taskId !== null || sshProbe.ok,
exitCode: sshProbe.exitCode,
stderrTail: sshProbe.stderr.slice(-1200),
}));
channels.push(channelProbe("provider-host-ssh", sshProbe.ok, "D601 source export, registry checks, kubectl/Tekton submission, and artifact summary reads", {
taskId: sshProbe.taskId,
status: sshProbe.status,
exitCode: sshProbe.exitCode,
stdoutTail: sshProbe.stdout.slice(-1200),
stderrTail: sshProbe.stderr.slice(-1200),
raw: sshProbe.ok ? undefined : dispatchPreflightFailure("host.ssh readonly probe", sshProbe).raw,
}));
const registryOptions = parseArtifactRegistryOptions(["--provider-id", providerId]);
const registryProbe = buildArtifactRegistryReadonlyProbe("health", registryOptions);
const registryDispatch = await transport.dispatchHostSsh(registryProbe.script, Math.max(registryProbe.timeoutMs, 30_000), registryProbe.timeoutMs);
const registryCommand = commandResultFromDispatch(transport.artifactRegistryCommand(registryProbe), transport.commandCwd, registryDispatch);
const registry = artifactRegistryReadonlyResultFromCommand(registryProbe, registryCommand);
const registryRecord = asRecord(registry);
const registryOk = registryRecord?.ok === true || registryRecord?.runtimeApiHealthy === true;
channels.push(channelProbe("artifact-registry", registryOk, "commit-pinned image push and later CD manifest checks", registry));
const missingChannels = channels.filter((item) => !item.ok).map((item) => item.channel);
const ready = missingChannels.length === 0;
return {
ok: ready,
runnerDisposition: ready ? "ready" : "infra-blocked",
serviceId: options.serviceId,
commit: options.commit,
providerId,
supportedArtifactPublish: true,
missingChannels,
channels,
registry,
next: ready
? [
`bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
`later CD must consume ${plannedArtifact.imageRef}; CI itself must not deploy production`,
]
: [
"Run from the main-server CLI or use remote frontend transport against a healthy frontend/backend-core path.",
"Restore backend-core/database/provider-gateway/Host SSH connectivity before retrying artifact publication.",
"Use bun scripts/cli.ts artifact-registry health --provider-id D601 to recheck registry reachability after the control bridge is restored.",
],
boundary: "preflight is read-only: no D601 source export, no Tekton PipelineRun, no image push, no deploy apply, no service restart",
};
}
async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise<ArtifactSummary> {
const result = await runRemoteKubectlRaw([
"set -euo pipefail",
@@ -1274,17 +1524,23 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
if (options.dryRun) {
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, localPublishPreflightTransport);
return {
ok: true,
mode: "dry-run",
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
channels: preflight.channels,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: true,
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
@@ -1299,10 +1555,8 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation",
next: [
`bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
],
boundary: preflight.boundary,
next: preflight.next,
};
}
const source = options.serviceId === "claudeqq"
@@ -1343,6 +1597,85 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
};
}
export async function runCiPublishUserServiceDryRunPreflight(
config: UniDeskConfig,
args: string[],
transport: PublishPreflightTransport,
): Promise<Record<string, unknown>> {
const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id"));
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
if (!args.includes("--dry-run")) throw new Error("publish-user-service preflight requires --dry-run");
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-user-service reads source repo from CI.json; edit CI.json instead of using --repo");
}
const artifact = resolveCatalogArtifact(serviceId);
if (artifact.kind === "source-build" && artifact.serviceId === "backend-core") {
throw new Error("backend-core uses ci publish-backend-core; publish-user-service is for registered user services");
}
if (artifact.kind === "upstream-image") {
return blockedArtifactResult(artifact, commit, artifact.blockedReason);
}
if (artifact.status === "blocked") {
return blockedArtifactResult(artifact, commit, blockedReason(artifact));
}
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
if (boundaryBlock !== null) return boundaryBlock;
const summaryContext: ArtifactSummaryContext = {
serviceId,
dockerfile,
commit,
repoUrl: artifact.source.repo,
imageRepository: artifact.image.repository,
};
const plannedArtifact = artifactSummaryDefaults(summaryContext);
const options: CiPublishUserServiceArtifactOptions = {
repoUrl: artifact.source.repo,
commit,
waitMs: numberOption(args, "--wait-ms", 0),
serviceId,
dockerfile,
imageRepository: artifact.image.repository,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun: true,
};
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, transport);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
return {
ok: preflight.ok,
mode: "dry-run-preflight",
runnerDisposition: preflight.runnerDisposition,
pipeline: "unidesk-user-service-artifact-publish",
namespace: "unidesk-ci",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
supportedArtifactPublish: preflight.supportedArtifactPublish,
missingChannels: preflight.missingChannels,
channels: preflight.channels,
registry: preflight.registry,
sourceHostPath: options.sourceHostPath,
source: {
ok: preflight.ok,
mode: "planned-only",
providerId: d601ProviderId,
repoUrl: options.repoUrl,
repoFetchUrl: plannedRepoFetchUrl,
repoProbeUrl: repoConnectivityProbeUrl(plannedRepoFetchUrl),
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
imageRepository: options.imageRepository,
sourceHostPath: options.sourceHostPath,
...(options.serviceId === "claudeqq" ? { overlay: "UniDesk claudeqq Dockerfile and unidesk-adapter.cjs are injected before Tekton build" } : {}),
},
artifact: plannedArtifact.imageRef,
artifactSummary: plannedArtifact,
boundary: preflight.boundary,
next: preflight.next,
};
}
async function runRemoteDevE2ELauncher(options: CiDevE2EOptions): Promise<DispatchResult> {
const scriptTimeoutMs = Math.max(options.scriptTimeoutMs, options.waitMs, 60_000);
const remoteTimeoutMs = 45_000;
@@ -1727,36 +2060,8 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
}
const repoUrl = artifact.source.repo;
const dockerfile = requireRepoRelativePath(artifact.source.dockerfile, `CI.json.artifacts.${serviceId}.source.dockerfile`);
const configService = config.microservices.find((item) => item.id === serviceId);
if (configService !== undefined) {
const isD601K3sService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "k3sctl-managed";
const isD601DirectService = configService.providerId === d601ProviderId
&& configService.development.providerId === d601ProviderId
&& configService.deployment.mode === "unidesk-direct";
const isMainServerDirectService = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "unidesk-direct";
const isMainServerInternalSidecar = configService.providerId === "main-server"
&& configService.development.providerId === "main-server"
&& configService.deployment.mode === "internal-sidecar";
if (!isD601K3sService && !isD601DirectService && !isMainServerDirectService && !isMainServerInternalSidecar) {
return {
ok: false,
status: "blocked",
error: "blocked",
serviceId,
commit,
reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
catalogArtifact: artifact,
configService: {
providerId: configService.providerId,
deploymentMode: configService.deployment.mode,
},
};
}
}
const boundaryBlock = userServicePublishBoundaryBlock(config, serviceId, commit, artifact);
if (boundaryBlock !== null) return boundaryBlock;
return publishUserServiceArtifact(config, {
repoUrl,
commit,
+122 -1
View File
@@ -6,6 +6,12 @@ import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync } from "./code-queue";
import { runDecisionCenterCommandAsync } from "./decision-center";
import {
artifactRegistryReadonlyResultFromCommand,
buildArtifactRegistryReadonlyProbe,
parseArtifactRegistryOptions,
} from "./artifact-registry";
import { runCiPublishUserServiceDryRunPreflight } from "./ci";
export interface RemoteCliOptions {
host: string | null;
@@ -517,6 +523,113 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | diagnostics <id> | tunnel-self-test <id> | proxy <id> <path>");
}
function commandResultFromFrontendTask(command: string[], task: { status?: string; result?: Record<string, unknown> } | undefined) {
const result = task?.result ?? {};
const stdout = typeof result.stdout === "string" ? result.stdout : "";
const stderr = typeof result.stderr === "string" ? result.stderr : "";
return {
command,
cwd: ".",
exitCode: typeof result.exitCode === "number" ? result.exitCode : null,
stdout,
stderr,
signal: null,
timedOut: task?.status !== "succeeded" && stderr.includes("timeout"),
};
}
async function dispatchHostSshJson(
session: FrontendSession,
providerId: string,
command: string,
timeoutMs: number,
waitMs = Math.max(timeoutMs + 5000, 20_000),
): Promise<{ dispatch: FetchJsonResult; wait: unknown; task?: { status?: string; result?: Record<string, unknown> }; taskId: string | null }> {
const dispatch = await frontendJson(session, "/api/dispatch", {
method: "POST",
body: JSON.stringify({
providerId,
command: "host.ssh",
payload: { source: "cli-remote-artifact-registry", mode: "exec", command, timeoutMs },
}),
});
const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
const wait = taskId.length > 0 ? await waitForFrontendTask(session, taskId, waitMs) : null;
const task = (wait as { task?: { status?: string; result?: Record<string, unknown> } } | null)?.task;
return { dispatch, wait, task, taskId: taskId.length > 0 ? taskId : null };
}
async function remoteArtifactRegistry(session: FrontendSession, args: string[]): Promise<unknown> {
const action = args[1] ?? "status";
if (action !== "status" && action !== "health") {
throw new Error("remote frontend transport supports artifact-registry status|health only; use main-server SSH for mutating install/deploy commands");
}
const options = parseArtifactRegistryOptions(args.slice(2));
const probe = buildArtifactRegistryReadonlyProbe(action, options);
const dispatched = await dispatchHostSshJson(session, probe.providerId, probe.script, probe.timeoutMs);
const command = ["frontend", "/api/dispatch", probe.providerId, "host.ssh", action];
const result = commandResultFromFrontendTask(command, dispatched.task);
const registryResult = artifactRegistryReadonlyResultFromCommand(probe, result);
return {
transport: "frontend",
readonly: true,
dispatch: dispatched.dispatch,
wait: dispatched.wait,
result: dispatched.taskId === null
? {
ok: false,
readonly: true,
installed: false,
healthy: false,
decision: "infra-blocked",
retryable: true,
runnerDisposition: "infra-blocked",
healthyScopes: [],
failedScopes: ["backend-core-api"],
runtimeApiHealthy: false,
channels: [
{ channel: "backend-core-api", ok: false, requiredFor: "frontend /api/dispatch backend-core session creation", detail: dispatched.dispatch },
{ channel: "provider-dispatch", ok: false, requiredFor: "host.ssh task creation", detail: dispatched.dispatch },
],
registry: registryResult,
}
: registryResult,
};
}
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");
}
return {
transport: "frontend",
readonly: true,
result: await runCiPublishUserServiceDryRunPreflight(config, args.slice(1), {
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],
}),
};
}
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
const action = args[1] ?? "task";
if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "output" && action !== "judge") {
@@ -610,7 +723,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, {
transport: "frontend",
baseUrl: session.baseUrl,
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex judge <taskId> --attempt N", "network perf"],
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex judge <taskId> --attempt N", "network perf"],
});
return 0;
}
@@ -630,6 +743,14 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, await remoteMicroservice(session, args));
return 0;
}
if (top === "artifact-registry") {
emitRemoteJson(name, await remoteArtifactRegistry(session, args));
return 0;
}
if (top === "ci") {
emitRemoteJson(name, await remoteCi(session, config, args));
return 0;
}
if (top === "decision" || top === "decision-center") {
const fetcher = (path: string, init?: { method?: string; body?: unknown }): Promise<FetchJsonResult> => {
const requestInit = init === undefined