refactor: split control-plane cli modules

This commit is contained in:
Codex
2026-06-25 16:16:00 +00:00
parent cca855daa3
commit e0af0f85ac
147 changed files with 63967 additions and 59999 deletions
@@ -0,0 +1,177 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. artifact-probe module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:2391-2578 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerSpec, ArtifactConsumerTarget, ArtifactRegistryOptions } from "./types";
import { base64, d601K3sGuardScript, shellQuote } from "./bundle";
import { artifactImageRef, deployRefFor, sourceRepoFor } from "./consumer";
export function registryArtifactProbeScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
const sourceImage = artifactImageRef(options, spec, commit);
return [
"set -euo pipefail",
`registry_image=${shellQuote(sourceImage)}`,
`manifest_url=${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)}`,
"headers=$(mktemp /tmp/unidesk-artifact-manifest.XXXXXX.headers)",
"trap 'rm -f \"$headers\"' EXIT",
"curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -D \"$headers\" -o /dev/null \"$manifest_url\"",
"manifest_digest=$(awk 'BEGIN{IGNORECASE=1} /^Docker-Content-Digest:/ {gsub(/\\r/, \"\", $2); print $2; exit}' \"$headers\")",
"printf 'registry_image=%s\\nmanifest_url=%s\\nmanifest_digest=%s\\n' \"$registry_image\" \"$manifest_url\" \"$manifest_digest\"",
].join("\n");
}
export function registryArtifactMissingMessage(spec: ArtifactConsumerSpec): string {
return `${spec.serviceId} image artifact is missing from D601 registry; run CI artifact publication first`;
}
export function verifyLocalArtifactLabels(
localLoadedImage: string,
spec: ArtifactConsumerSpec,
commit: string,
sourceRepo: string,
): Record<string, unknown> | null {
const inspectPulled = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{json .Config.Labels}}"], repoRoot);
const labelCommit = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot);
const labelService = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }}"], repoRoot);
const labelRepo = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/source-repo\" }}"], repoRoot);
const labelDockerfile = runCommand(["docker", "image", "inspect", localLoadedImage, "--format", "{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}"], repoRoot);
const observed = {
commit: labelCommit.stdout.trim(),
serviceId: labelService.stdout.trim(),
sourceRepo: labelRepo.stdout.trim(),
dockerfile: labelDockerfile.stdout.trim(),
labels: inspectPulled.stdout.trim(),
};
const ok = observed.commit === commit
&& observed.serviceId === spec.serviceId
&& observed.sourceRepo === sourceRepo
&& observed.dockerfile === spec.dockerfile;
if (ok) return null;
return {
ok: false,
step: "image-label-verify",
expected: { commit, serviceId: spec.serviceId, sourceRepo, dockerfile: spec.dockerfile },
observed,
};
}
export function d601DevFrontendAuthPatchScript(config: UniDeskConfig): string {
const sshClientToken = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_TOKEN");
if (sshClientToken === null) throw new Error("UNIDESK_SSH_CLIENT_TOKEN must be present in .state/docker-compose.env before deploying dev frontend");
const sshClientRouteAllowlist = composeRuntimeEnvValue("UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST") ?? "G14,G14:*,D601,D601:*";
const secretData = {
AUTH_USERNAME: base64(config.auth.username),
AUTH_PASSWORD: base64(config.auth.password),
SESSION_SECRET: base64(config.auth.sessionSecret),
UNIDESK_SSH_CLIENT_TOKEN: base64(sshClientToken),
};
const configData = {
SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds),
UNIDESK_SSH_CLIENT_ROUTE_ALLOWLIST: sshClientRouteAllowlist,
};
return [
d601K3sGuardScript(),
`secret_patch=${shellQuote(JSON.stringify({ data: secretData }))}`,
`config_patch=${shellQuote(JSON.stringify({ data: configData }))}`,
"kubectl -n unidesk-dev patch secret unidesk-dev-runtime-secrets --type merge -p \"$secret_patch\"",
"kubectl -n unidesk-dev patch configmap unidesk-dev-runtime-config --type merge -p \"$config_patch\"",
"echo artifact_cd_dev_frontend_auth_synced=ok",
].join("\n");
}
export function composeArtifactEnvValues(spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, options: ArtifactRegistryOptions, commit: string): Record<string, string> {
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
const prefix = target.compose.deployEnvPrefix;
return {
[`${prefix}_SERVICE_ID`]: spec.serviceId,
[`${prefix}_REF`]: deployRefFor(options, spec),
[`${prefix}_REPO`]: sourceRepoFor(options, spec),
[`${prefix}_COMMIT`]: commit,
[`${prefix}_REQUESTED_COMMIT`]: commit,
};
}
export function syntheticComposeHealthDeployScript(compose: NonNullable<ArtifactConsumerTarget["compose"]>, commit: string, sourceRepo: string): string[] {
if (compose.syntheticHealthDeployProof === undefined) return [];
const prefix = compose.deployEnvPrefix;
const envKeys = [
"UNIDESK_DEPLOY_SERVICE_ID",
"UNIDESK_DEPLOY_REF",
"UNIDESK_DEPLOY_REPO",
"UNIDESK_DEPLOY_COMMIT",
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
`${prefix}_SERVICE_ID`,
`${prefix}_REF`,
`${prefix}_REPO`,
`${prefix}_COMMIT`,
`${prefix}_REQUESTED_COMMIT`,
];
const envKeysJson = JSON.stringify(envKeys);
return [
"runtime_metadata_json=$(docker inspect \"$cid\" --format '{{json .Config.Env}}')",
"container_label_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-commit\" }}' \"$cid\")",
"container_label_requested_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-requested-commit\" }}' \"$cid\")",
"container_label_repo=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-repo\" }}' \"$cid\")",
"container_label_ref=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-ref\" }}' \"$cid\")",
`python3 - "$health_json" "$runtime_metadata_json" ${shellQuote(envKeysJson)} ${shellQuote(prefix)} ${shellQuote(commit)} ${shellQuote(sourceRepo)} "$actual_commit" "$actual_service" "$actual_repo" "$container_label_commit" "$container_label_requested_commit" "$container_label_repo" "$container_label_ref" <<'PY'`,
"import json, sys",
"path, env_json, keys_json, prefix, expected_commit, expected_repo, image_commit, image_service, image_repo, label_commit, label_requested_commit, label_repo, label_ref = sys.argv[1:]",
"body = json.load(open(path, encoding='utf-8'))",
"env_items = json.loads(env_json)",
"allow = set(json.loads(keys_json))",
"env = {}",
"for item in env_items:",
" if not isinstance(item, str) or '=' not in item:",
" continue",
" key, value = item.split('=', 1)",
" if key in allow:",
" env[key] = value",
"deploy = body.get('deploy') if isinstance(body, dict) and isinstance(body.get('deploy'), dict) else {}",
"synthetic = {",
" 'serviceId': deploy.get('serviceId') or env.get('UNIDESK_DEPLOY_SERVICE_ID') or env.get(prefix + '_SERVICE_ID') or image_service,",
" 'ref': deploy.get('ref') or env.get('UNIDESK_DEPLOY_REF') or env.get(prefix + '_REF') or label_ref,",
" 'repo': deploy.get('repo') or env.get('UNIDESK_DEPLOY_REPO') or env.get(prefix + '_REPO') or label_repo or image_repo,",
" 'commit': deploy.get('commit') or env.get('UNIDESK_DEPLOY_COMMIT') or env.get(prefix + '_COMMIT') or label_commit or image_commit,",
" 'requestedCommit': deploy.get('requestedCommit') or env.get('UNIDESK_DEPLOY_REQUESTED_COMMIT') or env.get(prefix + '_REQUESTED_COMMIT') or label_requested_commit,",
"}",
"body['deploy'] = synthetic",
"body['deployProof'] = {",
" 'kind': 'compose-container-runtime-metadata',",
" 'sources': ['service-health', 'container-env', 'container-labels', 'image-labels'],",
" 'sourceDirectoryUsed': False,",
" 'envKeysUsed': sorted([key for key in env if key.startswith('UNIDESK_DEPLOY_') or key.startswith(prefix + '_')]),",
"}",
"if synthetic.get('commit') != expected_commit or synthetic.get('requestedCommit') != expected_commit:",
" raise SystemExit('synthetic deploy commit mismatch: ' + json.dumps(synthetic, sort_keys=True))",
"if synthetic.get('repo') != expected_repo:",
" raise SystemExit('synthetic deploy repo mismatch: ' + json.dumps(synthetic, sort_keys=True))",
"if synthetic.get('serviceId') != image_service:",
" raise SystemExit('synthetic deploy service mismatch: ' + json.dumps(synthetic, sort_keys=True))",
"with open(path, 'w', encoding='utf-8') as handle:",
" json.dump(body, handle, separators=(',', ':'))",
" handle.write('\\n')",
"print('synthetic_health_deploy_proof=compose-container-runtime-metadata sourceDirectoryUsed=false')",
"PY",
];
}
+145
View File
@@ -0,0 +1,145 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. bundle module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:979-1080 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactRegistryOptions, RenderedBundle, RenderedFile } from "./types";
export function sha256(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function d601K3sGuardScript(): string {
return d601K3sGuardShellLines().join("\n");
}
export function base64(value: string): string {
return Buffer.from(value, "utf8").toString("base64");
}
export function safeName(value: string): string {
return value.replace(/[^A-Za-z0-9_.-]/gu, "-");
}
export function rootExecPrelude(): string {
return [
"root_exec() {",
" if [ \"$(id -u)\" = \"0\" ]; then \"$@\"; return; fi",
" if sudo -n true >/dev/null 2>&1; then sudo -n \"$@\"; return; fi",
" if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- \"$@\"; return; fi",
" echo 'artifact_registry_root_access=missing' >&2",
" return 1",
"}",
].join("\n");
}
export function file(path: string, content: string, mode = "0644"): RenderedFile {
return { path, mode, sha256: sha256(content), content };
}
export function registryConfig(): string {
return `version: 0.1
log:
fields:
service: unidesk-artifact-registry
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: false
http:
addr: :5000
headers:
X-Content-Type-Options: [nosniff]
health:
storagedriver:
enabled: true
interval: 10s
threshold: 3
`;
}
export function composeFile(options: ArtifactRegistryOptions): string {
return `name: ${options.composeProject}
services:
${options.serviceName}:
image: ${options.image}
container_name: ${options.containerName}
restart: unless-stopped
ports:
- "${options.host}:${options.port}:5000"
volumes:
- "${options.baseDir}/config.yml:/etc/docker/registry/config.yml:ro"
- "${options.storageDir}:/var/lib/registry"
labels:
unidesk.ai/service-id: artifact-registry
unidesk.ai/managed-by: host-systemd-docker-compose
unidesk.ai/provider-id: ${options.providerId}
`;
}
export function systemdUnit(options: ArtifactRegistryOptions): string {
return `[Unit]
Description=UniDesk D601 Artifact Registry (CNCF Distribution)
Documentation=https://github.com/pikasTech/unidesk/blob/master/docs/reference/artifact-registry.md
After=network-online.target
Wants=network-online.target
ConditionPathExists=/var/run/docker.sock
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${options.baseDir}
ExecStartPre=/bin/mkdir -p ${options.baseDir} ${options.storageDir}
ExecStartPre=/bin/sh -lc 'docker info >/dev/null'
ExecStart=/bin/sh -lc 'docker compose -p ${options.composeProject} -f ${options.baseDir}/compose.yml up -d --remove-orphans'
ExecStop=/bin/sh -lc 'docker compose -p ${options.composeProject} -f ${options.baseDir}/compose.yml down'
TimeoutStartSec=120
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
`;
}
export function renderBundle(options: ArtifactRegistryOptions): RenderedBundle {
const paths = {
unit: `/etc/systemd/system/${options.unitName}`,
compose: `${options.baseDir}/compose.yml`,
config: `${options.baseDir}/config.yml`,
storage: options.storageDir,
baseDir: options.baseDir,
};
return {
paths,
files: [
file(paths.config, registryConfig()),
file(paths.compose, composeFile(options)),
file(paths.unit, systemdUnit(options)),
],
};
}
+659
View File
@@ -0,0 +1,659 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. catalog module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:249-880 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerSpec, AuthHealthGate, RuntimeSecretRequirement } from "./types";
import { todoNoteSyntheticHealthDeployProof } from "./types";
export function todoNoteHealthProbeCommand(): string {
return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"";
}
export const baiduNetdiskRuntimeSecretRequirements: RuntimeSecretRequirement[] = [
{ sourceEnvName: "UNIDESK_BAIDU_NETDISK_CLIENT_ID", containerEnvName: "BAIDU_NETDISK_CLIENT_ID" },
{ sourceEnvName: "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET", containerEnvName: "BAIDU_NETDISK_CLIENT_SECRET" },
{ sourceEnvName: "UNIDESK_BAIDU_NETDISK_TOKEN_KEY", containerEnvName: "BAIDU_NETDISK_TOKEN_KEY" },
];
export const baiduNetdiskAuthHealthGate: AuthHealthGate = {
requiredFields: ["configured", "clientIdConfigured", "clientSecretConfigured", "tokenKeyConfigured", "loggedIn"],
recoveryHint: "Restore UNIDESK_BAIDU_NETDISK_CLIENT_ID, UNIDESK_BAIDU_NETDISK_CLIENT_SECRET, and UNIDESK_BAIDU_NETDISK_TOKEN_KEY in the canonical .state/docker-compose.env, recreate only baidu-netdisk with the canonical env file, then verify microservice health baidu-netdisk.",
};
export const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
"auth-broker": {
serviceId: "auth-broker",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/auth-broker",
dockerfile: "src/components/microservices/auth-broker/Dockerfile",
prodLiveApply: "supervisor-only",
prodLiveBlockReason: "auth-broker is registered for source/contract/profile dry-run only; live production apply requires a separate credential mounting and exposure review.",
devLiveApply: "supervisor-only",
devLiveBlockReason: "auth-broker DEV live apply requires explicit operator authorization after reviewing credential mounting, private exposure, and dry-run evidence.",
targets: {
dev: {
targetImage: "auth-broker",
targetCommitImage: (commit: string) => `auth-broker:${commit}`,
deployRef: "deploy.json#environments.dev.services.auth-broker",
compose: {
serviceName: "auth-broker",
containerName: "auth-broker-backend",
deployEnvPrefix: "UNIDESK_AUTH_BROKER_DEPLOY",
healthProbeCommand: "auth-broker --healthcheck",
requireHealthCommit: false,
},
},
prod: {
targetImage: "auth-broker",
targetCommitImage: (commit: string) => `auth-broker:${commit}`,
deployRef: "deploy.json#environments.prod.services.auth-broker",
compose: {
serviceName: "auth-broker",
containerName: "auth-broker-backend",
deployEnvPrefix: "UNIDESK_AUTH_BROKER_DEPLOY",
healthProbeCommand: "auth-broker --healthcheck",
requireHealthCommit: false,
},
},
},
},
"backend-core": {
serviceId: "backend-core",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/backend-core",
dockerfile: "src/components/backend-core/Dockerfile",
prodLiveApply: "enabled",
targets: {
prod: {
targetImage: "unidesk-backend-core",
targetCommitImage: (commit: string) => `unidesk-backend-core:${commit}`,
deployRef: "deploy.json#environments.prod.services.backend-core",
compose: {
serviceName: "backend-core",
containerName: "unidesk-backend-core",
deployEnvPrefix: "UNIDESK_DEPLOY",
healthProbeCommand: "if command -v backend-core >/dev/null 2>&1; then backend-core --fetch-json http://127.0.0.1:8080/health --require-ok; elif command -v bun >/dev/null 2>&1; then bun -e \"fetch('http://127.0.0.1:8080/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"; else exit 1; fi",
requireHealthCommit: false,
},
},
},
},
"dev:backend-core": {
serviceId: "backend-core",
environment: "dev",
kind: "d601-k3s",
registryRepository: "unidesk/backend-core",
dockerfile: "src/components/backend-core/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "unidesk-backend-core:dev",
targetCommitImage: (commit: string) => `unidesk-backend-core:${commit}`,
deployRef: "origin/master:deploy.json#environments.dev.services.backend-core",
k3s: {
namespace: "unidesk-dev",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml",
deploymentName: "backend-core-dev",
serviceName: "backend-core-dev",
servicePort: 8080,
containerName: "backend-core",
healthPath: "/health",
applySelector: "app.kubernetes.io/name=backend-core",
podLabelSelector: "app.kubernetes.io/name=backend-core,app.kubernetes.io/component=core,unidesk.ai/environment=dev",
},
},
},
},
"baidu-netdisk": {
serviceId: "baidu-netdisk",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/baidu-netdisk",
dockerfile: "src/components/microservices/baidu-netdisk/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "baidu-netdisk",
targetCommitImage: (commit: string) => `baidu-netdisk:${commit}`,
deployRef: "deploy.json#environments.dev.services.baidu-netdisk",
compose: {
serviceName: "baidu-netdisk",
containerName: "baidu-netdisk-backend",
deployEnvPrefix: "UNIDESK_BAIDU_NETDISK_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4244/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
requiredRuntimeSecrets: baiduNetdiskRuntimeSecretRequirements,
authHealthGate: baiduNetdiskAuthHealthGate,
},
},
prod: {
targetImage: "baidu-netdisk",
targetCommitImage: (commit: string) => `baidu-netdisk:${commit}`,
deployRef: "deploy.json#environments.prod.services.baidu-netdisk",
compose: {
serviceName: "baidu-netdisk",
containerName: "baidu-netdisk-backend",
deployEnvPrefix: "UNIDESK_BAIDU_NETDISK_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4244/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
requiredRuntimeSecrets: baiduNetdiskRuntimeSecretRequirements,
authHealthGate: baiduNetdiskAuthHealthGate,
},
},
},
},
"code-queue-mgr": {
serviceId: "code-queue-mgr",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/code-queue-mgr",
dockerfile: "src/components/microservices/code-queue-mgr/Dockerfile",
prodLiveApply: "supervisor-only",
prodLiveBlockReason: "code-queue-mgr is the main-server Code Queue control-plane sidecar; live production apply requires explicit supervisor confirmation.",
targets: {
dev: {
targetImage: "code-queue-mgr",
targetCommitImage: (commit: string) => `code-queue-mgr:${commit}`,
deployRef: "deploy.json#environments.dev.services.code-queue-mgr",
compose: {
serviceName: "code-queue-mgr",
containerName: "code-queue-mgr-backend",
deployEnvPrefix: "UNIDESK_CODE_QUEUE_MGR_DEPLOY",
healthProbeCommand: "code-queue-mgr --print-health",
requireHealthCommit: true,
},
},
prod: {
targetImage: "code-queue-mgr",
targetCommitImage: (commit: string) => `code-queue-mgr:${commit}`,
deployRef: "deploy.json#environments.prod.services.code-queue-mgr",
compose: {
serviceName: "code-queue-mgr",
containerName: "code-queue-mgr-backend",
deployEnvPrefix: "UNIDESK_CODE_QUEUE_MGR_DEPLOY",
healthProbeCommand: "code-queue-mgr --print-health",
requireHealthCommit: true,
},
},
},
},
"decision-center": {
serviceId: "decision-center",
environment: "prod",
kind: "d601-k3s",
registryRepository: "unidesk/decision-center",
dockerfile: "src/components/microservices/decision-center/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "unidesk-decision-center:dev",
targetCommitImage: (commit: string) => `unidesk-decision-center:${commit}`,
deployRef: "deploy.json#environments.dev.services.decision-center",
k3s: {
namespace: "unidesk-dev",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-decision-center.k8s.yaml",
deploymentName: "decision-center-dev",
serviceName: "decision-center-dev",
servicePort: 4277,
containerName: "decision-center",
healthPath: "/health",
podLabelSelector: "app.kubernetes.io/name=decision-center,unidesk.ai/environment=dev",
},
},
prod: {
targetImage: "unidesk-decision-center:d601",
targetCommitImage: (commit: string) => `unidesk-decision-center:${commit}`,
deployRef: "deploy.json#environments.prod.services.decision-center",
k3s: {
namespace: "unidesk",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml",
deploymentName: "decision-center",
serviceName: "decision-center",
servicePort: 4277,
containerName: "decision-center",
healthPath: "/health",
},
},
},
},
"mdtodo": {
serviceId: "mdtodo",
environment: "prod",
kind: "d601-k3s",
registryRepository: "unidesk/mdtodo",
dockerfile: "src/components/microservices/mdtodo/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "unidesk-mdtodo:dev",
targetCommitImage: (commit: string) => `unidesk-mdtodo:${commit}`,
deployRef: "deploy.json#environments.dev.services.mdtodo",
k3s: {
namespace: "unidesk-dev",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-mdtodo.k8s.yaml",
deploymentName: "mdtodo-dev",
serviceName: "mdtodo-dev",
servicePort: 4267,
containerName: "mdtodo",
healthPath: "/health",
podLabelSelector: "app.kubernetes.io/name=mdtodo,unidesk.ai/environment=dev",
},
},
prod: {
targetImage: "unidesk-mdtodo:d601",
targetCommitImage: (commit: string) => `unidesk-mdtodo:${commit}`,
deployRef: "deploy.json#environments.prod.services.mdtodo",
k3s: {
namespace: "unidesk",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/mdtodo.k8s.yaml",
deploymentName: "mdtodo",
serviceName: "mdtodo",
servicePort: 4267,
containerName: "mdtodo",
healthPath: "/health",
},
},
},
},
"claudeqq": {
serviceId: "claudeqq",
environment: "prod",
kind: "d601-k3s",
registryRepository: "unidesk/claudeqq",
dockerfile: "claudeqq/Dockerfile",
sourceRepo: "https://gitee.com/lyon1998/agent_skills",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "unidesk-claudeqq:dev",
targetCommitImage: (commit: string) => `unidesk-claudeqq:${commit}`,
deployRef: "deploy.json#environments.dev.services.claudeqq",
k3s: {
namespace: "unidesk-dev",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-claudeqq.k8s.yaml",
deploymentName: "claudeqq-dev",
serviceName: "claudeqq-dev",
servicePort: 3290,
containerName: "claudeqq",
healthPath: "/health",
podLabelSelector: "app.kubernetes.io/name=claudeqq,unidesk.ai/environment=dev",
},
},
prod: {
targetImage: "unidesk-claudeqq:d601",
targetCommitImage: (commit: string) => `unidesk-claudeqq:${commit}`,
deployRef: "deploy.json#environments.prod.services.claudeqq",
k3s: {
namespace: "unidesk",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/claudeqq.k8s.yaml",
deploymentName: "claudeqq",
serviceName: "claudeqq",
servicePort: 3290,
containerName: "claudeqq",
healthPath: "/health",
},
},
},
},
"code-queue": {
serviceId: "code-queue",
environment: "dev",
kind: "d601-k3s",
registryRepository: "unidesk/code-queue",
dockerfile: "src/components/microservices/code-queue/Dockerfile",
prodLiveApply: "unsupported",
prodLiveBlockReason: "code-queue is dev-only for artifact consumer validation and has no production artifact deploy, production rollout, or production manifest mutation target.",
devLiveApply: "supervisor-only",
devLiveBlockReason: "Code Queue DEV live apply is self-bootstrap sensitive: a running Code Queue task may plan only dry-run evidence. A human operator or supervisor must explicitly authorize DEV apply outside Code Queue after reviewing the CI artifact digest and dry-run target list.",
targets: {
dev: {
targetImage: "unidesk-code-queue:dev",
targetCommitImage: (commit: string) => `unidesk-code-queue:${commit}`,
deployRef: "deploy.json#environments.dev.services.code-queue",
k3s: {
namespace: "unidesk-dev",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml",
deploymentName: "code-queue-scheduler-dev",
extraDeployments: [
{ name: "d601-dev-provider-egress-proxy", containerName: "provider-egress-proxy" },
"code-queue-read-dev",
"code-queue-write-dev",
],
serviceName: "code-queue-scheduler-dev",
servicePort: 4222,
containerName: "code-queue",
healthPath: "/health",
podLabelSelector: "app.kubernetes.io/name=code-queue,app.kubernetes.io/component=scheduler,unidesk.ai/environment=dev",
},
},
},
},
"frontend": {
serviceId: "frontend",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/frontend",
dockerfile: "src/components/frontend/Dockerfile",
prodLiveApply: "enabled",
targets: {
prod: {
targetImage: "unidesk-frontend",
targetCommitImage: (commit: string) => `unidesk-frontend:${commit}`,
deployRef: "deploy.json#environments.prod.services.frontend",
compose: {
serviceName: "frontend",
containerName: "unidesk-frontend",
deployEnvPrefix: "UNIDESK_FRONTEND_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:8080/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
},
},
},
},
"findjob": {
serviceId: "findjob",
environment: "prod",
kind: "d601-compose",
sourceRepo: "https://gitee.com/Lyon1998/findjob",
registryRepository: "unidesk/findjob",
dockerfile: "Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "findjob-server",
targetCommitImage: (commit: string) => `findjob-server:${commit}`,
deployRef: "deploy.json#environments.dev.services.findjob",
compose: {
serviceName: "server",
containerName: "findjob-server",
deployEnvPrefix: "UNIDESK_FINDJOB_DEPLOY",
workDir: "/home/ubuntu/findjob",
composeFile: "docker-compose.yml",
projectHint: "findjob",
healthProbeCommand: "curl -fsS --max-time 12 http://127.0.0.1:3254/api/health",
requireHealthCommit: false,
},
},
prod: {
targetImage: "findjob-server",
targetCommitImage: (commit: string) => `findjob-server:${commit}`,
deployRef: "deploy.json#environments.prod.services.findjob",
compose: {
serviceName: "server",
containerName: "findjob-server",
deployEnvPrefix: "UNIDESK_FINDJOB_DEPLOY",
workDir: "/home/ubuntu/findjob",
composeFile: "docker-compose.yml",
projectHint: "findjob",
healthProbeCommand: "curl -fsS --max-time 12 http://127.0.0.1:3254/api/health",
requireHealthCommit: false,
},
},
},
},
"k3sctl-adapter": {
serviceId: "k3sctl-adapter",
environment: "prod",
kind: "d601-compose",
registryRepository: "unidesk/k3sctl-adapter",
dockerfile: "src/components/microservices/k3sctl-adapter/Dockerfile",
prodLiveApply: "supervisor-only",
prodLiveBlockReason: "k3sctl-adapter is an infrastructure control bridge; this executor exposes artifact consumer plan/dry-run only. Real production deployment requires supervisor confirmation outside this task.",
targets: {
prod: {
targetImage: "unidesk-k3sctl-adapter:d601",
targetCommitImage: (commit: string) => `unidesk-k3sctl-adapter:${commit}`,
deployRef: "deploy.json#environments.prod.services.k3sctl-adapter",
compose: {
serviceName: "k3sctl-adapter",
containerName: "k3sctl-adapter",
deployEnvPrefix: "UNIDESK_K3SCTL_ADAPTER_DEPLOY",
workDir: "/home/ubuntu/cq-deploy",
composeFile: "src/components/microservices/k3sctl-adapter/docker-compose.d601.yml",
projectHint: "k3sctl-adapter",
healthProbeCommand: "curl -fsS --max-time 10 http://127.0.0.1:4266/health",
requireHealthCommit: false,
},
},
},
},
"met-nonlinear": {
serviceId: "met-nonlinear",
environment: "prod",
kind: "d601-compose",
sourceRepo: "https://github.com/pikasTech/met_nonlinear",
registryRepository: "unidesk/met-nonlinear",
dockerfile: "docker/unidesk/Dockerfile.ml",
prodLiveApply: "unsupported",
prodLiveBlockReason: "met-nonlinear is blocked for live artifact deploy because config.json points at docker/unidesk/Dockerfile.ml while the compose service is met-nonlinear-ts. The current compose contract does not let CD prove that the recreated long-running container image label equals the requested commit.",
runtimeVerification: "blocked",
runtimeVerificationBlockReason: "D601 direct artifact consumer is implemented, but this service's registered Dockerfile is the ML image contract while the long-running Compose service is met-nonlinear-ts. Publish a labeled artifact that matches the running service image contract before live deploy, or update the service contract to a server Dockerfile.",
targets: {
dev: {
targetImage: "met-nonlinear-ml:tf26",
targetCommitImage: (commit: string) => `met-nonlinear-ml:${commit}`,
deployRef: "deploy.json#environments.dev.services.met-nonlinear",
compose: {
serviceName: "met-nonlinear-ts",
containerName: "met-nonlinear-ts",
deployEnvPrefix: "UNIDESK_MET_NONLINEAR_DEPLOY",
workDir: "/home/ubuntu/met_nonlinear",
composeFile: "docker-compose.unidesk.yml",
projectHint: "met-nonlinear",
healthProbeCommand: "curl -fsS --max-time 20 http://127.0.0.1:3288/health",
requireHealthCommit: false,
},
},
prod: {
targetImage: "met-nonlinear-ml:tf26",
targetCommitImage: (commit: string) => `met-nonlinear-ml:${commit}`,
deployRef: "deploy.json#environments.prod.services.met-nonlinear",
compose: {
serviceName: "met-nonlinear-ts",
containerName: "met-nonlinear-ts",
deployEnvPrefix: "UNIDESK_MET_NONLINEAR_DEPLOY",
workDir: "/home/ubuntu/met_nonlinear",
composeFile: "docker-compose.unidesk.yml",
projectHint: "met-nonlinear",
healthProbeCommand: "curl -fsS --max-time 20 http://127.0.0.1:3288/health",
requireHealthCommit: false,
},
},
},
},
"oa-event-flow": {
serviceId: "oa-event-flow",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/oa-event-flow",
dockerfile: "src/components/microservices/oa-event-flow/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "oa-event-flow",
targetCommitImage: (commit: string) => `oa-event-flow:${commit}`,
deployRef: "deploy.json#environments.dev.services.oa-event-flow",
compose: {
serviceName: "oa-event-flow",
containerName: "oa-event-flow-backend",
deployEnvPrefix: "UNIDESK_OA_EVENT_FLOW_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4255/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
},
},
prod: {
targetImage: "oa-event-flow",
targetCommitImage: (commit: string) => `oa-event-flow:${commit}`,
deployRef: "deploy.json#environments.prod.services.oa-event-flow",
compose: {
serviceName: "oa-event-flow",
containerName: "oa-event-flow-backend",
deployEnvPrefix: "UNIDESK_OA_EVENT_FLOW_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4255/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
},
},
},
},
"project-manager": {
serviceId: "project-manager",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/project-manager",
dockerfile: "src/components/microservices/project-manager/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "project-manager",
targetCommitImage: (commit: string) => `project-manager:${commit}`,
deployRef: "deploy.json#environments.dev.services.project-manager",
compose: {
serviceName: "project-manager",
containerName: "project-manager-backend",
deployEnvPrefix: "UNIDESK_PROJECT_MANAGER_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4233/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
},
},
prod: {
targetImage: "project-manager",
targetCommitImage: (commit: string) => `project-manager:${commit}`,
deployRef: "deploy.json#environments.prod.services.project-manager",
compose: {
serviceName: "project-manager",
containerName: "project-manager-backend",
deployEnvPrefix: "UNIDESK_PROJECT_MANAGER_DEPLOY",
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4233/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
requireHealthCommit: true,
},
},
},
},
"pipeline": {
serviceId: "pipeline",
environment: "prod",
kind: "d601-compose",
sourceRepo: "https://github.com/pikasTech/pipeline",
registryRepository: "unidesk/pipeline",
dockerfile: "Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "pipeline-v2-control",
targetCommitImage: (commit: string) => `pipeline-v2-control:${commit}`,
deployRef: "deploy.json#environments.dev.services.pipeline",
compose: {
serviceName: "pipeline-control",
containerName: "pipeline-v2-control",
deployEnvPrefix: "UNIDESK_PIPELINE_DEPLOY",
workDir: "/home/ubuntu/pipeline",
composeFile: "docker-compose.yml",
projectHint: "pipeline",
healthProbeCommand: "curl -fsS --max-time 20 http://127.0.0.1:18082/health",
requireHealthCommit: false,
},
},
prod: {
targetImage: "pipeline-v2-control",
targetCommitImage: (commit: string) => `pipeline-v2-control:${commit}`,
deployRef: "deploy.json#environments.prod.services.pipeline",
compose: {
serviceName: "pipeline-control",
containerName: "pipeline-v2-control",
deployEnvPrefix: "UNIDESK_PIPELINE_DEPLOY",
workDir: "/home/ubuntu/pipeline",
composeFile: "docker-compose.yml",
projectHint: "pipeline",
healthProbeCommand: "curl -fsS --max-time 20 http://127.0.0.1:18082/health",
requireHealthCommit: false,
},
},
},
},
"todo-note": {
serviceId: "todo-note",
environment: "prod",
kind: "compose",
registryRepository: "unidesk/todo-note",
sourceRepo: "https://gitee.com/Lyon1998/todo_note",
dockerfile: "Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "todo-note",
targetCommitImage: (commit: string) => `todo-note:${commit}`,
deployRef: "deploy.json#environments.dev.services.todo-note",
compose: {
serviceName: "todo-note",
containerName: "todo-note-backend",
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
healthProbeCommand: todoNoteHealthProbeCommand(),
requireHealthCommit: true,
syntheticHealthDeployProof: todoNoteSyntheticHealthDeployProof,
},
},
prod: {
targetImage: "todo-note",
targetCommitImage: (commit: string) => `todo-note:${commit}`,
deployRef: "deploy.json#environments.prod.services.todo-note",
compose: {
serviceName: "todo-note",
containerName: "todo-note-backend",
deployEnvPrefix: "UNIDESK_TODO_NOTE_DEPLOY",
healthProbeCommand: todoNoteHealthProbeCommand(),
requireHealthCommit: true,
syntheticHealthDeployProof: todoNoteSyntheticHealthDeployProof,
},
},
},
},
"dev:frontend": {
serviceId: "frontend",
environment: "dev",
kind: "d601-k3s",
registryRepository: "unidesk/frontend",
dockerfile: "src/components/frontend/Dockerfile",
prodLiveApply: "enabled",
targets: {
dev: {
targetImage: "unidesk-frontend:dev",
targetCommitImage: (commit: string) => `unidesk-frontend:${commit}`,
deployRef: "origin/master:deploy.json#environments.dev.services.frontend",
k3s: {
namespace: "unidesk-dev",
manifestRepoPath: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml",
deploymentName: "frontend-dev",
serviceName: "frontend-dev",
servicePort: 8080,
containerName: "frontend",
healthPath: "/health",
applySelector: "app.kubernetes.io/name=frontend",
podLabelSelector: "app.kubernetes.io/name=frontend,app.kubernetes.io/component=web,unidesk.ai/environment=dev",
},
},
},
},
};
@@ -0,0 +1,610 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. compose-deploy module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:2579-3153 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerSpec, ArtifactConsumerTarget, ArtifactRegistryOptions } from "./types";
import { composeArtifactEnvValues, registryArtifactMissingMessage, registryArtifactProbeScript, syntheticComposeHealthDeployScript, verifyLocalArtifactLabels } from "./artifact-probe";
import { base64, safeName, shellQuote } from "./bundle";
import { artifactConsumerSpecs } from "./catalog";
import { artifactConsumerLiveBlockReason, artifactConsumerLivePolicy, artifactConsumerSelfBootstrapGuard, artifactConsumerTarget, artifactImageRef, artifactRegistryDeployJsonMirrors, codeQueueAffectedRuntime, codeQueueExcludedTargets, commandTail, deployJsonServiceForOptions, deployRefFor, registryRepositoryFor, sourceRepoFor, targetCommitImageFor, targetImageFor, unsupportedEnvironment } from "./consumer";
import { composeLockScript } from "./install";
import { rollbackInfo } from "./k3s-deploy";
import { runReadonlyStatus } from "./readonly";
import { runRemoteScript } from "./remote";
import { authHealthGateScript, composeArtifactRuntime, composeArtifactSecretPreflight, pullArtifactFromD601, runtimeSecretContractForComposeTarget, runtimeSecretRecommendedActionForService, upsertEnvFileValues } from "./runtime-secrets";
export async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
const config = readConfig();
const runtime = composeArtifactRuntime(config, target);
const secretPreflight = composeArtifactSecretPreflight(runtime.envFile, target.compose.requiredRuntimeSecrets);
const runtimeSecretsRaw = runtimeSecretContractForComposeTarget(config, target, target.compose.requiredRuntimeSecrets);
const runtimeSecrets = runtimeSecretsRaw === undefined
? undefined
: runtimeSecretRecommendedActionForService(runtimeSecretsRaw, options.environment ?? "prod", spec.serviceId);
if (!secretPreflight.ok) {
return {
ok: false,
supported: true,
serviceId: spec.serviceId,
step: "runtime-secret-preflight",
error: "runtime-secret-presence-missing",
environment: options.environment ?? "prod",
envFile: runtime.envFile,
requirements: secretPreflight.requirements,
missing: secretPreflight.missing,
secretSource: runtimeSecrets?.secretSource,
requiredSecretsPresent: runtimeSecrets?.requiredSecretsPresent ?? false,
missingSecretKeys: runtimeSecrets?.missingSecretKeys ?? secretPreflight.missing.map((item) => item.sourceEnvName),
recommendedAction: runtimeSecrets?.recommendedAction ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.",
runtimeSecrets,
valuesLogged: false,
recoveryHint: target.compose.authHealthGate?.recoveryHint ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.",
};
}
const health = runReadonlyStatus(options, true);
if (health.ok !== true) {
return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
}
const sourceImage = artifactImageRef(options, spec, commit);
const sourceRepo = sourceRepoFor(options, spec);
const composeImage = options.targetImage ?? target.targetImage;
const commitImage = `${composeImage}:${commit}`;
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
return {
ok: false,
serviceId: spec.serviceId,
step: "registry-artifact-check",
error: registryArtifactMissingMessage(spec),
sourceImage,
registryProbe: commandTail(registryProbe),
};
}
const pull = await pullArtifactFromD601(options, sourceImage);
if (pull.exitCode !== 0 || pull.timedOut) {
return {
ok: false,
serviceId: spec.serviceId,
step: "docker-load",
sourceImage,
registryProbe: commandTail(registryProbe),
pull: commandTail(pull),
};
}
const localLoadedImage = sourceImage;
const labelFailure = verifyLocalArtifactLabels(localLoadedImage, spec, commit, sourceRepo);
if (labelFailure !== null) return { ...labelFailure, registryProbe: commandTail(registryProbe) };
const tag = runCommand(["docker", "tag", localLoadedImage, composeImage], repoRoot);
if (tag.exitCode !== 0 || tag.timedOut) {
return { ok: false, step: "docker-tag", targetImage: composeImage, tag: commandTail(tag), registryProbe: commandTail(registryProbe) };
}
const tagCommit = runCommand(["docker", "tag", localLoadedImage, commitImage], repoRoot);
if (tagCommit.exitCode !== 0 || tagCommit.timedOut) {
return { ok: false, step: "docker-tag-commit", targetImage: commitImage, tag: commandTail(tagCommit), registryProbe: commandTail(registryProbe) };
}
upsertEnvFileValues(runtime.envFile, {
...composeArtifactEnvValues(spec, target, options, commit),
});
const compose = runtime.command;
const composeProject = runtime.project;
const serviceName = target.compose.serviceName;
const containerName = target.compose.containerName;
const serviceLogPrefix = spec.serviceId.replace(/-/gu, "_");
const upScript = [
"set -euo pipefail",
`echo ${shellQuote(`${serviceLogPrefix}_artifact_cd source=${sourceImage} target=${composeImage}`)}`,
`${compose.map(shellQuote).join(" ")} up -d --no-build --no-deps --force-recreate ${shellQuote(serviceName)}`,
"ready=0",
"for attempt in $(seq 1 60); do",
` cid=$(docker ps -q --filter label=com.docker.compose.project=${shellQuote(composeProject)} --filter label=com.docker.compose.service=${shellQuote(serviceName)} --filter label=com.docker.compose.oneoff=False | head -1 || true)`,
" if [ -n \"$cid\" ]; then",
" health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)",
` echo "${serviceLogPrefix}_container_probe attempt=$attempt cid=$cid health=$health"`,
" if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
" else",
` echo "${serviceLogPrefix}_container_probe attempt=$attempt cid=missing"`,
" fi",
" sleep 1",
"done",
"test \"$ready\" = \"1\"",
`cid=$(docker ps -q --filter label=com.docker.compose.project=${shellQuote(composeProject)} --filter label=com.docker.compose.service=${shellQuote(serviceName)} --filter label=com.docker.compose.oneoff=False | head -1)`,
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
"actual_commit=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")",
"actual_service=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/service-id\" }}' \"$image_id\")",
"actual_repo=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}' \"$image_id\")",
`test "$actual_commit" = ${shellQuote(commit)}`,
`test "$actual_service" = ${shellQuote(spec.serviceId)}`,
`test "$actual_repo" = ${shellQuote(sourceRepo)}`,
`health_json=/tmp/unidesk-${safeName(spec.serviceId)}-health.json`,
`docker exec "$cid" sh -lc ${shellQuote(target.compose.healthProbeCommand)} > "$health_json"`,
...syntheticComposeHealthDeployScript(target.compose, commit, sourceRepo),
"cat \"$health_json\"",
...authHealthGateScript(target.compose.authHealthGate, "\"$health_json\""),
...(target.compose.requireHealthCommit ? [
"health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_json\")",
"health_requested_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"requestedCommit\") or \"\"))' \"$health_json\")",
`test "$health_commit" = ${shellQuote(commit)}`,
`test "$health_requested_commit" = ${shellQuote(commit)}`,
] : []),
].join("\n");
const deploy = runCommand(["bash", "-lc", composeLockScript(upScript)], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 300_000) });
if (deploy.exitCode !== 0 || deploy.timedOut) {
return {
ok: false,
serviceId: spec.serviceId,
step: "compose-recreate",
sourceImage,
targetImage: composeImage,
registryProbe: commandTail(registryProbe),
runtimeSecretPreflight: secretPreflight,
deploy: commandTail(deploy),
recoveryHint: target.compose.authHealthGate?.recoveryHint ?? undefined,
};
}
const running = runCommand(["docker", "inspect", containerName, "--format", "image={{.Config.Image}} imageId={{.Image}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}"], repoRoot);
return {
ok: true,
supported: true,
serviceId: spec.serviceId,
commit,
providerId: options.providerId,
sourceImage,
registryProbe: commandTail(registryProbe),
localLoadedImage,
targetImage: composeImage,
targetCommitImage: commitImage,
pull: commandTail(pull),
deploy: commandTail(deploy),
running: running.stdout.trim(),
validation: {
liveCommit: commit,
liveRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
imageLabelCommit: commit,
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
runtimeProof: target.compose.syntheticHealthDeployProof === undefined ? "native-health" : target.compose.syntheticHealthDeployProof,
requiredRuntimeSecrets: secretPreflight.requirements,
runtimeSecrets,
authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : {
requiredFields: target.compose.authHealthGate.requiredFields,
passed: true,
},
healthyOldVersionAccepted: false,
},
rollback: {
previousImageHint: `Use docker image ls and docker inspect logs for the previous ${spec.serviceId} image id; Compose named volumes were not changed.`,
commandShape: `${compose.map(shellQuote).join(" ")} up -d --no-build --no-deps --force-recreate ${serviceName}`,
},
};
}
export async function deployBackendCoreNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
const spec = artifactConsumerSpecs["backend-core"];
const target = artifactConsumerTarget(spec, options.environment);
if (target === null) return unsupportedEnvironment(spec, options);
return deployComposeArtifactNow(options, spec, target);
}
export function d601ComposeArtifactDeployScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, commit: string): string {
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
const compose = target.compose;
const sourceImage = artifactImageRef(options, spec, commit);
const sourceRepo = sourceRepoFor(options, spec);
const commitImage = target.targetCommitImage(commit);
const envValues = composeArtifactEnvValues(spec, target, options, commit);
const labels = {
"unidesk.ai/deploy-service-id": spec.serviceId,
"unidesk.ai/deploy-ref": deployRefFor(options, spec),
"unidesk.ai/deploy-repo": sourceRepo,
"unidesk.ai/deploy-commit": commit,
"unidesk.ai/deploy-requested-commit": commit,
"unidesk.ai/image-source": sourceImage,
"unidesk.ai/deploy-environment": options.environment ?? "prod",
};
const override = {
services: {
[compose.serviceName]: {
image: "${UNIDESK_ARTIFACT_STABLE_IMAGE}",
labels,
environment: {
UNIDESK_DEPLOY_SERVICE_ID: spec.serviceId,
UNIDESK_DEPLOY_REF: deployRefFor(options, spec),
UNIDESK_DEPLOY_REPO: sourceRepo,
UNIDESK_DEPLOY_COMMIT: commit,
UNIDESK_DEPLOY_REQUESTED_COMMIT: commit,
...envValues,
},
},
},
};
return [
"set -euo pipefail",
`registry_image=${shellQuote(sourceImage)}`,
`stable_image=${shellQuote(target.targetImage)}`,
`commit_image=${shellQuote(commitImage)}`,
`service_id=${shellQuote(spec.serviceId)}`,
`source_repo=${shellQuote(sourceRepo)}`,
`deploy_ref=${shellQuote(deployRefFor(options, spec))}`,
`commit=${shellQuote(commit)}`,
`dockerfile=${shellQuote(spec.dockerfile)}`,
`work_dir=${shellQuote(compose.workDir ?? "/home/ubuntu")}`,
`compose_file=${shellQuote(compose.composeFile ?? "docker-compose.yml")}`,
`compose_service=${shellQuote(compose.serviceName)}`,
`container=${shellQuote(compose.containerName)}`,
`project_hint=${shellQuote(compose.projectHint ?? "")}`,
`health_probe_b64=${shellQuote(base64(compose.healthProbeCommand))}`,
`override_b64=${shellQuote(base64(JSON.stringify(override, null, 2)))}`,
"command -v docker >/dev/null",
"docker compose version >/dev/null",
`curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' ${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)} >/dev/null`,
"docker pull -q \"$registry_image\" >/dev/null",
"label_commit=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}')",
"label_service=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/service-id\" }}')",
"label_repo=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}')",
"label_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')",
"test \"$label_commit\" = \"$commit\"",
"test \"$label_service\" = \"$service_id\"",
"test \"$label_repo\" = \"$source_repo\"",
"test \"$label_dockerfile\" = \"$dockerfile\"",
"test -d \"$work_dir\"",
"test -f \"$work_dir/$compose_file\"",
"docker tag \"$registry_image\" \"$stable_image\"",
"docker tag \"$registry_image\" \"$commit_image\"",
"override=\"$work_dir/.unidesk-artifact-consumer.override.yml\"",
"printf '%s' \"$override_b64\" | base64 -d > \"$override\"",
"export UNIDESK_ARTIFACT_STABLE_IMAGE=\"$stable_image\"",
"project=$(docker inspect \"$container\" --format '{{ index .Config.Labels \"com.docker.compose.project\" }}' 2>/dev/null || true)",
"if [ -z \"$project\" ]; then project=\"$project_hint\"; fi",
"if [ -z \"$project\" ]; then project=$(basename \"$work_dir\"); fi",
"cd \"$work_dir\"",
"docker compose -p \"$project\" -f \"$compose_file\" -f \"$override\" up -d --no-build --no-deps --force-recreate \"$compose_service\"",
"ready=0",
"for attempt in $(seq 1 90); do",
" cid=$(docker ps -q --filter label=com.docker.compose.project=\"$project\" --filter label=com.docker.compose.service=\"$compose_service\" --filter label=com.docker.compose.oneoff=False | head -1 || true)",
" if [ -n \"$cid\" ]; then",
" health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)",
" echo \"artifact_cd_container_probe attempt=$attempt cid=$cid health=$health\"",
" if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
" else",
" echo \"artifact_cd_container_probe attempt=$attempt cid=missing\"",
" fi",
" sleep 2",
"done",
"test \"$ready\" = \"1\"",
"cid=$(docker ps -q --filter label=com.docker.compose.project=\"$project\" --filter label=com.docker.compose.service=\"$compose_service\" --filter label=com.docker.compose.oneoff=False | head -1)",
"image_id=$(docker inspect -f '{{.Image}}' \"$cid\")",
"actual_commit=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")",
"actual_service=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/service-id\" }}' \"$image_id\")",
"container_commit=$(docker inspect -f '{{ index .Config.Labels \"unidesk.ai/deploy-commit\" }}' \"$cid\")",
"test \"$actual_commit\" = \"$commit\"",
"test \"$actual_service\" = \"$service_id\"",
"test \"$container_commit\" = \"$commit\"",
"health_probe=$(printf '%s' \"$health_probe_b64\" | base64 -d)",
"docker exec \"$cid\" sh -lc \"$health_probe\" >/tmp/unidesk-artifact-health.out",
"cat /tmp/unidesk-artifact-health.out",
...authHealthGateScript(compose.authHealthGate, shellQuote("/tmp/unidesk-artifact-health.out")),
"printf 'artifact_cd_service=%s\\nartifact_cd_source_repo=%s\\nartifact_cd_deploy_ref=%s\\nartifact_cd_source_image=%s\\nartifact_cd_stable_image=%s\\nartifact_cd_runtime_image=%s\\nartifact_cd_commit=%s\\nartifact_cd_container=%s\\nartifact_cd_container_id=%s\\nartifact_cd_image_label_commit=%s\\nartifact_cd_container_label_commit=%s\\n' \"$service_id\" \"$source_repo\" \"$deploy_ref\" \"$registry_image\" \"$stable_image\" \"$commit_image\" \"$commit\" \"$container\" \"$cid\" \"$actual_commit\" \"$container_commit\"",
].join("\n");
}
export async function deployD601ComposeArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
const environment = options.environment ?? "prod";
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
const health = runReadonlyStatus(options, true);
if (health.ok !== true) return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
const sourceImage = artifactImageRef(options, spec, commit);
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
return {
ok: false,
supported: true,
serviceId: spec.serviceId,
step: "registry-artifact-check",
error: registryArtifactMissingMessage(spec),
sourceImage,
registryProbe: commandTail(registryProbe),
};
}
const deploy = runRemoteScript(options, d601ComposeArtifactDeployScript(options, spec, target, commit), Math.max(options.timeoutMs, 420_000));
if (deploy.exitCode !== 0 || deploy.timedOut) {
return {
ok: false,
supported: true,
serviceId: spec.serviceId,
step: "d601-compose-artifact-deploy",
sourceImage,
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
rollback: rollbackInfo(spec, target, environment, commit),
};
}
return {
ok: true,
supported: true,
serviceId: spec.serviceId,
environment,
commit,
providerId: options.providerId,
sourceRepo: sourceRepoFor(options, spec),
deployRef: deployRefFor(options, spec),
sourceImage,
targetImage: target.targetImage,
targetCommitImage: target.targetCommitImage(commit),
composeService: target.compose.serviceName,
containerName: target.compose.containerName,
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
validation: {
liveCommit: commit,
liveRequestedCommit: commit,
imageLabelCommit: commit,
containerDeployLabelCommit: commit,
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
healthyOldVersionAccepted: false,
},
rollback: rollbackInfo(spec, target, environment, commit),
};
}
export function deployBackendCoreJob(args: string[], options: ArtifactRegistryOptions): Record<string, unknown> {
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
const spec = artifactConsumerSpecs["backend-core"];
const target = artifactConsumerTarget(spec, options.environment);
if (target === null) return unsupportedEnvironment(spec, options);
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
const command = [process.execPath, rootPath("scripts", "cli.ts"), "artifact-registry", ...runArgs];
const job = startJob("artifact_registry_backend_core_cd", command, `Pull and deploy backend-core artifact ${options.commit} from D601 registry`);
return {
ok: true,
mode: "async-job",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
note: "Backend-core CD continues in the background: D601 registry health, provider-gateway SSH image stream, docker load, retag, no-build recreate, live commit verification.",
};
}
export function legacyDeployBackendCoreResult(options: ArtifactRegistryOptions): Record<string, unknown> {
return {
ok: false,
supported: false,
deprecated: true,
action: "deploy-backend-core",
replacement: "bun scripts/cli.ts deploy apply --env prod --service backend-core --commit <full-sha>",
serviceId: "backend-core",
environment: options.environment ?? "prod",
commit: options.commit,
policy: "backend-core production CD must enter through deploy apply --env prod so the standard artifact-consumer guardrails run before any mutation; the legacy artifact-registry deploy-backend-core entry is retained only as a documented compatibility name.",
};
}
export function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, commit: string): Record<string, unknown> {
const environment = options.environment ?? "prod";
const deployJsonService = deployJsonServiceForOptions(options, spec, environment);
const effectiveOptions = deployJsonService === options.deployJsonService ? options : { ...options, deployJsonService };
const drifts = deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService)
? compareDeployJsonExecutorMirrors(deployJsonService, environment, artifactRegistryDeployJsonMirrors(effectiveOptions, spec, target))
: [];
if (deployJsonService !== null && drifts.length > 0) return deployJsonDriftResult(deployJsonService, environment, drifts);
const verificationBlocked = spec.runtimeVerification === "blocked";
const livePolicy = artifactConsumerLivePolicy(spec, environment);
const liveReason = artifactConsumerLiveBlockReason(spec, environment);
const requiresSupervisorApproval = livePolicy === "supervisor-only" || spec.serviceId === "code-queue";
const sourceImage = artifactImageRef(effectiveOptions, spec, commit);
const registryEndpoint = `http://127.0.0.1:${effectiveOptions.port}`;
const config = readConfig();
const contractTarget = deployJsonService?.consumer?.target;
const contractRuntime = deployJsonService?.runtime;
const k3sDeployments = target.k3s === undefined
? []
: [
{ name: contractTarget?.deployment ?? target.k3s.deploymentName, containerName: contractTarget?.containerName ?? target.k3s.containerName },
...(contractTarget === undefined ? (target.k3s.extraDeployments ?? []) : []).map((deployment) => typeof deployment === "string"
? { name: deployment, containerName: target.k3s!.containerName }
: { name: deployment.name, containerName: deployment.containerName ?? target.k3s!.containerName }),
];
const common = {
ok: !verificationBlocked,
supported: !verificationBlocked,
dryRun: true,
mutation: false,
error: verificationBlocked ? "runtime-verification-blocked" : undefined,
environment,
providerId: effectiveOptions.providerId,
serviceId: spec.serviceId,
commit,
sourceRepo: sourceRepoFor(effectiveOptions, spec),
deployRef: deployRefFor(effectiveOptions, spec),
sourceImage,
source: {
repo: sourceRepoFor(effectiveOptions, spec),
commit,
dockerfile: spec.dockerfile,
},
registry: {
endpoint: registryEndpoint,
repository: registryRepositoryFor(effectiveOptions, spec),
tag: commit,
imageRef: sourceImage,
digest: null,
digestHeader: "Docker-Content-Digest",
digestSource: "planned registry manifest HEAD; live apply must read this digest before mutation",
},
build: {
willCompile: false,
willRunCargoBuild: false,
willRunDockerBuild: false,
willRunDockerComposeBuild: false,
producerBoundary: spec.serviceId === "backend-core" ? "ci publish-backend-core" : "ci publish-user-service",
},
requiredLabels: {
"unidesk.ai/service-id": spec.serviceId,
"unidesk.ai/source-repo": sourceRepoFor(effectiveOptions, spec),
"unidesk.ai/source-commit": commit,
"unidesk.ai/dockerfile": spec.dockerfile,
},
registryProbe: {
method: "HEAD",
url: `${registryEndpoint}/v2/${registryRepositoryFor(effectiveOptions, spec)}/manifests/${commit}`,
digestHeader: "Docker-Content-Digest",
},
boundary: `${environment} CD is artifact-consumer only: verify commit-pinned registry image, pull/import, deploy, then verify live commit/image/health; it never builds source on the runtime target`,
liveApply: {
policy: livePolicy,
allowed: !verificationBlocked && livePolicy === "enabled",
requiresSupervisorApproval,
reason: liveReason,
},
requiresSupervisorApproval,
selfBootstrapGuard: artifactConsumerSelfBootstrapGuard(spec, environment, requiresSupervisorApproval),
affectedRuntime: spec.serviceId === "code-queue" ? codeQueueAffectedRuntime(environment, true) : undefined,
sourceOfTruth: deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService) ? deployJsonSourceOfTruth(deployJsonService, environment) : undefined,
driftCheck: deployJsonService !== null && hasDeployJsonExecutorContract(deployJsonService) ? {
ok: true,
mirrors: ["artifact-registry-executor", ...(deployJsonService.consumer?.kind === "d601-k3s-managed" ? ["k8s-manifest"] : [])],
} : undefined,
};
if (spec.kind === "compose" || spec.kind === "d601-compose") {
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
const runtimeSecretsRaw = runtimeSecretContractForComposeTarget(config, target, target.compose.requiredRuntimeSecrets);
const runtimeSecrets = runtimeSecretsRaw === undefined ? undefined : runtimeSecretRecommendedActionForService(runtimeSecretsRaw, environment, spec.serviceId);
return {
...common,
target: {
kind: spec.kind,
runtimeHost: spec.kind === "d601-compose" ? "D601" : "main-server",
workDir: target.compose.workDir,
composeFile: target.compose.composeFile,
composeService: target.compose.serviceName,
containerName: target.compose.containerName,
targetImage: options.targetImage ?? target.targetImage,
runtimeImage: target.targetCommitImage(commit),
deployEnvPrefix: target.compose.deployEnvPrefix,
deployCommandShape: `docker compose up -d --no-build --no-deps --force-recreate ${target.compose.serviceName}`,
},
excludedTargets: spec.serviceId === "code-queue-mgr"
? [
{
serviceId: "code-queue",
reason: "code-queue-mgr is the main-server control-plane sidecar only; scheduler/runner live in the separate Code Queue execution plane and are not part of this artifact consumer.",
},
{
services: ["code-queue-scheduler", "code-queue-runner"],
reason: "dry-run does not mutate D601 Code Queue scheduler, runner, tasks, interrupts, or cancellations.",
},
]
: spec.serviceId === "code-queue"
? codeQueueExcludedTargets(environment)
: undefined,
validation: [
"D601 registry /v2 manifest exists for the commit tag",
spec.kind === "d601-compose"
? "D601-pulled image labels match service id, source repo, source commit, and Dockerfile"
: "loaded image labels match service id, source repo, source commit, and Dockerfile",
spec.kind === "d601-compose"
? "running D601 Compose container is recreated with a no-build override that points the service image at the artifact"
: "running Compose container image label matches the requested commit",
...(spec.kind === "d601-compose" ? ["running Compose container image label matches the requested commit"] : []),
verificationBlocked
? `blocked: ${spec.runtimeVerificationBlockReason}`
: target.compose.requireHealthCommit && target.compose.syntheticHealthDeployProof !== undefined
? `${spec.serviceId} runtime health proof synthesizes deploy.commit/deploy.requestedCommit from Compose container runtime metadata and verifies it against image/container labels, not source directory guesses`
: target.compose.requireHealthCommit
? `${spec.serviceId} runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit`
: `${spec.serviceId} /health succeeds for the recreated container`,
...(target.compose.requiredRuntimeSecrets === undefined ? [] : [
`${spec.serviceId} live apply checks required runtime secret presence in the canonical Compose env file without reading or printing values`,
]),
...(target.compose.authHealthGate === undefined ? [] : [
`${spec.serviceId} live apply gates success on /health.auth fields: ${target.compose.authHealthGate.requiredFields.join(", ")}`,
]),
],
runtimeSecrets,
authHealthGate: target.compose.authHealthGate === undefined ? undefined : {
path: "/health",
requiredAuthFields: target.compose.authHealthGate.requiredFields,
dryRunDisposition: "pending-live-check",
},
runtimeProof: target.compose.syntheticHealthDeployProof === undefined ? {
kind: "native-health-deploy-metadata",
sourceDirectoryUsed: false,
} : {
...target.compose.syntheticHealthDeployProof,
sourceDirectoryUsed: false,
sources: ["service-health", "container-env", "container-labels", "image-labels"],
requiredEnvKeys: [
"UNIDESK_DEPLOY_SERVICE_ID",
"UNIDESK_DEPLOY_REF",
"UNIDESK_DEPLOY_REPO",
"UNIDESK_DEPLOY_COMMIT",
"UNIDESK_DEPLOY_REQUESTED_COMMIT",
`${target.compose.deployEnvPrefix}_SERVICE_ID`,
`${target.compose.deployEnvPrefix}_REF`,
`${target.compose.deployEnvPrefix}_REPO`,
`${target.compose.deployEnvPrefix}_COMMIT`,
`${target.compose.deployEnvPrefix}_REQUESTED_COMMIT`,
],
},
rollback: rollbackInfo(spec, target, environment, commit),
};
}
return {
...common,
target: {
kind: "d601-k3s",
namespace: contractTarget?.namespace ?? target.k3s?.namespace,
deployment: contractTarget?.deployment ?? target.k3s?.deploymentName,
deployments: k3sDeployments,
service: contractTarget?.service ?? target.k3s?.serviceName,
stableImage: targetImageFor(effectiveOptions, target),
runtimeImage: targetCommitImageFor(effectiveOptions, target, commit),
manifestRepoPath: contractTarget?.manifestRepoPath ?? target.k3s?.manifestRepoPath,
deployCommandShape: "kubectl set image + set env + annotate + rollout status",
},
runtime: contractRuntime === undefined ? undefined : {
sourceOfTruth: "deploy.json",
containerPort: contractRuntime.containerPort,
healthPath: contractRuntime.healthPath,
memory: contractRuntime.memory,
health: contractRuntime.health,
},
excludedTargets: spec.serviceId === "code-queue" ? codeQueueExcludedTargets(environment) : undefined,
validation: [
"D601 registry /v2 manifest exists for the commit tag before mutation",
"D601 Docker-pulled image labels match service id, source repo, source commit, and Dockerfile",
"native k3s containerd has the commit image and stable runtime image tag",
"Deployment annotation and pod image id label match the requested commit",
"service health via Kubernetes API service proxy returns the same deploy.commit and deploy.requestedCommit",
...(environment === "dev" && spec.serviceId === "frontend" ? ["dev frontend auth/session config is synced from main-server config before rollout"] : []),
],
rollback: rollbackInfo(spec, target, environment, commit),
};
}
+367
View File
@@ -0,0 +1,367 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. consumer module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:1319-1658 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerSpec, ArtifactConsumerTarget, ArtifactDeployEnvironment, ArtifactRegistryOptions, SupportedArtifactConsumerService } from "./types";
import { artifactConsumerSpecs } from "./catalog";
import { supportedArtifactConsumerServices } from "./types";
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,
signal: result.signal,
timedOut: result.timedOut,
stdoutTail: result.stdout.slice(-4000),
stderrTail: result.stderr.slice(-4000),
};
}
export function commandTail(result: CommandResult): Record<string, unknown> {
return artifactRegistryCommandTail(result);
}
export function artifactConsumerSpec(serviceId: string, environment: ArtifactDeployEnvironment | null): ArtifactConsumerSpec | null {
const key = environment === null || environment === "prod" ? serviceId : `${environment}:${serviceId}`;
const explicit = artifactConsumerSpecs[key];
if (explicit !== undefined) return explicit;
const shared = artifactConsumerSpecs[serviceId];
return shared ?? null;
}
export function supportedArtifactConsumers(): Array<{ environment: ArtifactDeployEnvironment; serviceId: SupportedArtifactConsumerService; kind: ArtifactConsumerSpec["kind"] }> {
return Object.values(artifactConsumerSpecs).flatMap((spec) => Object.keys(spec.targets).map((environment) => ({
environment: environment as ArtifactDeployEnvironment,
serviceId: spec.serviceId,
kind: spec.kind,
})));
}
export function artifactConsumerTarget(spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment | null): ArtifactConsumerTarget | null {
return spec.targets[environment ?? "prod"] ?? null;
}
export function unsupportedService(serviceId: string, options: ArtifactRegistryOptions): Record<string, unknown> {
return {
ok: false,
supported: false,
error: "unsupported",
serviceId,
environment: options.environment ?? "prod",
providerId: options.providerId,
reason: "No standardized D601 registry CD consumer is implemented for this service.",
supportedServices: supportedArtifactConsumerServices,
supportedConsumers: supportedArtifactConsumers(),
policy: "unsupported services must not silently fall back to maintenance-channel source builds or legacy direct deployment",
};
}
export function unsupportedEnvironment(spec: ArtifactConsumerSpec, options: ArtifactRegistryOptions): Record<string, unknown> {
const environment = options.environment ?? "prod";
const codeQueueGuard = spec.serviceId === "code-queue" ? codeQueueSelfBootstrapGuard(environment) : undefined;
const reason = spec.serviceId === "code-queue" && environment === "prod"
? spec.prodLiveBlockReason ?? "Code Queue production artifact deploy, rollout and manifest mutation are intentionally unsupported in this phase."
: `No standardized ${environment} registry artifact consumer is implemented for ${spec.serviceId}.`;
return {
ok: false,
supported: false,
error: "unsupported-environment",
serviceId: spec.serviceId,
environment,
providerId: options.providerId,
reason,
supportedEnvironments: Object.keys(spec.targets),
requiresSupervisorApproval: spec.serviceId === "code-queue",
selfBootstrapGuard: codeQueueGuard,
affectedRuntime: spec.serviceId === "code-queue" ? codeQueueAffectedRuntime(environment, false) : undefined,
excludedTargets: spec.serviceId === "code-queue" ? codeQueueExcludedTargets(environment) : undefined,
policy: "artifact CD must not silently fall back to maintenance-channel source builds or legacy direct deployment",
};
}
export function artifactConsumerLivePolicy(spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment): "enabled" | "supervisor-only" | "unsupported" {
return environment === "prod" ? spec.prodLiveApply : spec.devLiveApply ?? "enabled";
}
export function artifactConsumerLiveBlockReason(spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment): string | null {
if (spec.runtimeVerificationBlockReason !== undefined) return spec.runtimeVerificationBlockReason;
return environment === "prod" ? spec.prodLiveBlockReason ?? null : spec.devLiveBlockReason ?? null;
}
export function codeQueueExcludedTargets(environment: ArtifactDeployEnvironment): Record<string, unknown>[] {
const excluded: Record<string, unknown>[] = [
{
namespace: "unidesk",
deployments: ["code-queue", "code-queue-read", "code-queue-write", "d601-provider-egress-proxy", "d601-tcp-egress-gateway"],
reason: "production Code Queue execution-plane Deployments are outside this artifact consumer and must not be mutated by dry-run or self-bootstrap automation.",
},
{
operations: ["restart scheduler", "restart runner", "interrupt active tasks", "cancel active tasks"],
reason: "artifact CD planning must not alter scheduler/runner lifecycle or active task control state.",
},
];
if (environment === "dev") {
excluded.push({
namespace: "unidesk-dev",
nonDryRunMutation: "requires explicit supervisor or human authorization outside the running Code Queue task",
reason: "DEV is the only Code Queue artifact consumer target, but self-bootstrap automation may produce dry-run evidence only.",
});
}
return excluded;
}
export function codeQueueAffectedRuntime(environment: ArtifactDeployEnvironment, targetPlanned: boolean): Record<string, unknown> {
return {
dryRunMutation: false,
plannedTarget: targetPlanned && environment === "dev"
? {
namespace: "unidesk-dev",
deployments: ["code-queue-scheduler-dev", "code-queue-read-dev", "code-queue-write-dev", "d601-dev-provider-egress-proxy"],
}
: null,
productionNamespaceAffected: false,
productionSchedulerRunnerAffected: false,
activeTaskControlAffected: false,
activeTaskInterruptCancelAffected: false,
};
}
export function codeQueueSelfBootstrapGuard(environment: ArtifactDeployEnvironment): Record<string, unknown> {
return {
check: "code-queue-self-bootstrap-guard",
serviceId: "code-queue",
selfBootstrapBlocked: true,
requiresSupervisorApproval: true,
actorBoundary: "a running Code Queue task may publish contract evidence and dry-run plans only; it must not deploy Code Queue itself",
devApply: "requires explicit supervisor or human authorization outside the running Code Queue task",
prodApply: "unsupported until a separate supervisor-approved production Code Queue CD design exists",
allowedWithoutApproval: [
"CI artifact publication",
"deploy plan",
"deploy apply --dry-run",
"artifact-registry deploy-service --dry-run",
],
forbiddenActions: [
"self-deploy Code Queue from Code Queue",
"run a non-dry-run DEV apply from Code Queue",
"production namespace mutation",
"production manifest mutation",
"scheduler or runner restart",
"active task interrupt",
"active task cancel",
],
environment,
};
}
export function codeQueueMgrSelfBootstrapGuard(environment: ArtifactDeployEnvironment, requiresSupervisorApproval: boolean): Record<string, unknown> {
return {
check: "code-queue-mgr-control-plane-guard",
serviceId: "code-queue-mgr",
selfBootstrapBlocked: true,
requiresSupervisorApproval,
actorBoundary: "dry-run is allowed, but a running Code Queue task must not replace the Code Queue control-plane sidecar as part of delivering Code Queue itself",
targetScope: "main-server Compose service code-queue-mgr / container code-queue-mgr-backend only",
prodApply: "requires explicit supervisor confirmation",
environment,
forbiddenActions: [
"mutate D601 Code Queue scheduler",
"mutate D601 Code Queue runner",
"restart active tasks",
"interrupt active tasks",
"cancel active tasks",
],
};
}
export function authBrokerCredentialMountGuard(environment: ArtifactDeployEnvironment, requiresSupervisorApproval: boolean): Record<string, unknown> {
return {
check: "auth-broker-credential-mount-guard",
serviceId: "auth-broker",
requiresSupervisorApproval,
actorBoundary: "dry-run and contract evidence are allowed, but live broker startup needs explicit review of credential reference mounting and private exposure",
targetScope: "main-server Compose profile auth-broker / container auth-broker-backend only",
composeProfileRequired: "auth-broker",
publicPortAllowed: false,
registryCredentialsProxied: false,
deployCredentialsProxied: false,
environment,
forbiddenActions: [
"write real GitHub token into config.json, deploy.json, or docker-compose.yml",
"publish auth-broker on a public port",
"restart backend-core, provider-gateway, or Code Queue as part of broker dry-run registration",
"grant registry, deploy, database, k3s, provider token, or host SSH permissions",
],
};
}
export function artifactConsumerSelfBootstrapGuard(
spec: ArtifactConsumerSpec,
environment: ArtifactDeployEnvironment,
requiresSupervisorApproval: boolean,
): Record<string, unknown> | undefined {
if (spec.serviceId === "code-queue") return codeQueueSelfBootstrapGuard(environment);
if (spec.serviceId === "code-queue-mgr") return codeQueueMgrSelfBootstrapGuard(environment, requiresSupervisorApproval);
if (spec.serviceId === "auth-broker") return authBrokerCredentialMountGuard(environment, requiresSupervisorApproval);
return undefined;
}
export function artifactConsumerLiveBlock(spec: ArtifactConsumerSpec, options: ArtifactRegistryOptions): Record<string, unknown> | null {
const environment = options.environment ?? "prod";
const livePolicy = artifactConsumerLivePolicy(spec, environment);
const liveReason = artifactConsumerLiveBlockReason(spec, environment);
if (spec.runtimeVerification === "blocked") {
return {
ok: false,
supported: false,
liveApplyAllowed: false,
error: "runtime-verification-blocked",
serviceId: spec.serviceId,
environment,
providerId: options.providerId,
reason: spec.runtimeVerificationBlockReason ?? `${spec.serviceId} does not yet satisfy strict runtime deploy commit verification.`,
requiredBeforeLiveApply: [
"runtime Compose env injects deploy commit/requestedCommit metadata",
"service health reports deploy.commit and deploy.requestedCommit for strict verification",
],
policy: "artifact CD must not accept a healthy old service or silently fall back to legacy rebuild paths",
};
}
if (livePolicy === "enabled") return null;
if (livePolicy === "supervisor-only") {
return {
ok: false,
supported: true,
liveApplyAllowed: false,
error: "supervisor-confirmation-required",
serviceId: spec.serviceId,
environment,
providerId: options.providerId,
reason: liveReason ?? `${spec.serviceId} ${environment} artifact apply requires supervisor confirmation.`,
requiresSupervisorApproval: true,
selfBootstrapGuard: artifactConsumerSelfBootstrapGuard(spec, environment, true),
affectedRuntime: spec.serviceId === "code-queue" ? codeQueueAffectedRuntime(environment, true) : undefined,
excludedTargets: spec.serviceId === "code-queue" ? codeQueueExcludedTargets(environment) : undefined,
dryRunCommandShape: `bun scripts/cli.ts artifact-registry deploy-service --env ${environment} --service ${spec.serviceId} --commit <full-sha> --dry-run`,
policy: "worker automation must not perform live apply for this supervisor-gated artifact consumer",
};
}
return {
ok: false,
supported: false,
liveApplyAllowed: false,
error: "artifact-consumer-blocked",
serviceId: spec.serviceId,
environment,
providerId: options.providerId,
reason: liveReason ?? `${spec.serviceId} does not yet satisfy the artifact consumer runtime verification contract.`,
requiresSupervisorApproval: spec.serviceId === "code-queue",
selfBootstrapGuard: spec.serviceId === "code-queue" ? codeQueueSelfBootstrapGuard(environment) : undefined,
affectedRuntime: spec.serviceId === "code-queue" ? codeQueueAffectedRuntime(environment, false) : undefined,
excludedTargets: spec.serviceId === "code-queue" ? codeQueueExcludedTargets(environment) : undefined,
requiredBeforeLiveApply: [
"CI can publish a commit-pinned image with matching service id, source repo, source commit, and Dockerfile labels",
"runtime Compose env injects deploy commit/requestedCommit metadata",
"service health reports deploy.commit and deploy.requestedCommit for strict verification",
],
policy: "do not silently fall back to server rebuild, dirty worktrees, mutable tags, or source builds on the runtime target",
};
}
export function artifactImageRef(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, commit: string): string {
return `127.0.0.1:${options.port}/${(options.dryRun ? options.deployJsonService?.artifact?.repository : undefined) ?? spec.registryRepository}:${commit}`;
}
export function sourceRepoFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string {
return options.sourceRepoExplicit ? options.sourceRepo : spec.sourceRepo ?? options.sourceRepo;
}
export function deployRefFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string {
const target = artifactConsumerTarget(spec, options.environment);
return options.deployRef ?? (options.dryRun ? options.deployJsonService?.consumer?.targetRef : undefined) ?? target?.deployRef ?? `deploy.json#environments.${options.environment ?? "prod"}.services.${spec.serviceId}`;
}
export function deployJsonServiceForOptions(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment): DeployJsonServiceContract | null {
if (!options.dryRun) return null;
if (options.deployJsonService !== null) return options.deployJsonService;
if (environment === "dev" && (spec.serviceId === "decision-center" || spec.serviceId === "mdtodo")) {
return readDeployJsonServiceContractFromFile(environment, spec.serviceId);
}
return null;
}
export function targetImageFor(options: ArtifactRegistryOptions, target: ArtifactConsumerTarget): string {
return (options.dryRun ? options.deployJsonService?.consumer?.target.stableImage : undefined) ?? target.targetImage;
}
export function targetCommitImageFor(options: ArtifactRegistryOptions, target: ArtifactConsumerTarget, commit: string): string {
const stableImage = options.dryRun ? options.deployJsonService?.consumer?.target.stableImage : undefined;
return stableImage === undefined ? target.targetCommitImage(commit) : deployJsonCommitImage(stableImage, commit);
}
export function registryRepositoryFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string {
return (options.dryRun ? options.deployJsonService?.artifact?.repository : undefined) ?? spec.registryRepository;
}
export function artifactRegistryDeployJsonMirrors(
options: ArtifactRegistryOptions,
spec: ArtifactConsumerSpec,
target: ArtifactConsumerTarget,
): DeployJsonExecutorMirror[] {
const mirrors: DeployJsonExecutorMirror[] = [
{
surface: "artifact-registry-executor",
artifact: {
kind: "source-build",
repository: spec.registryRepository,
tag: "commitId",
},
consumer: {
kind: spec.kind === "d601-k3s" ? "d601-k3s-managed" : spec.kind,
noRuntimeSourceBuild: true,
target: target.k3s === undefined ? undefined : {
namespace: target.k3s.namespace,
deployment: target.k3s.deploymentName,
service: target.k3s.serviceName,
containerName: target.k3s.containerName,
stableImage: target.targetImage,
manifestRepoPath: target.k3s.manifestRepoPath,
},
},
runtime: target.k3s === undefined ? undefined : {
containerPort: target.k3s.servicePort,
servicePort: target.k3s.servicePort,
healthPath: target.k3s.healthPath,
health: options.deployJsonService?.runtime?.health === undefined ? undefined : {
deployMetadataRequired: true,
},
},
},
];
if (options.deployJsonService !== null) {
const manifestMirror = k3sManifestExecutorMirror(options.deployJsonService);
if (manifestMirror !== null) mirrors.push(manifestMirror);
}
return mirrors;
}
+190
View File
@@ -0,0 +1,190 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. entry module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:3448-3700 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactRegistryAction, ArtifactRegistryCommandRuntime, ArtifactRegistryOptions } from "./types";
import { renderBundle } from "./bundle";
import { deployBackendCoreJob, deployBackendCoreNow, deployComposeArtifactNow, deployD601ComposeArtifactNow, dryRunArtifactConsumerPlan, legacyDeployBackendCoreResult } from "./compose-deploy";
import { artifactConsumerLiveBlock, artifactConsumerSpec, artifactConsumerTarget, supportedArtifactConsumers, unsupportedEnvironment, unsupportedService } from "./consumer";
import { install, installDryRun } from "./install";
import { deployD601K3sArtifactNow, rollbackInfo } from "./k3s-deploy";
import { isHelpArg, parseArtifactRegistryOptions } from "./options";
import { runReadonlyStatusWithRemoteFallback } from "./readonly";
import { plan } from "./status";
import { defaultOptions, legacyDeployBackendCoreDisabled, supportedArtifactConsumerServices } from "./types";
export async function deployServiceNow(options: ArtifactRegistryOptions): Promise<Record<string, unknown>> {
if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service <id>");
if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
const spec = artifactConsumerSpec(options.serviceId, options.environment);
if (spec === null) return unsupportedService(options.serviceId, options);
const target = artifactConsumerTarget(spec, options.environment);
if (target === null) return unsupportedEnvironment(spec, options);
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, target, options.commit);
const liveBlock = artifactConsumerLiveBlock(spec, options);
if (liveBlock !== null) return liveBlock;
if (spec.kind === "compose") return deployComposeArtifactNow(options, spec, target);
if (spec.kind === "d601-compose") return deployD601ComposeArtifactNow(options, spec, target);
return deployD601K3sArtifactNow(options, spec, target);
}
export function deployServiceJob(args: string[], options: ArtifactRegistryOptions): Record<string, unknown> {
if (options.serviceId === null) throw new Error("artifact-registry deploy-service requires --service <id>");
if (options.commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
const spec = artifactConsumerSpec(options.serviceId, options.environment);
if (spec === null) return unsupportedService(options.serviceId, options);
const target = artifactConsumerTarget(spec, options.environment);
if (target === null) return unsupportedEnvironment(spec, options);
if (options.dryRun) return dryRunArtifactConsumerPlan(options, spec, target, options.commit);
const liveBlock = artifactConsumerLiveBlock(spec, options);
if (liveBlock !== null) return liveBlock;
const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"];
const command = [process.execPath, rootPath("scripts", "cli.ts"), "artifact-registry", ...runArgs];
const job = startJob("artifact_registry_service_cd", command, `Pull and deploy ${options.environment ?? "prod"} ${options.serviceId} artifact ${options.commit} from D601 registry`);
return {
ok: true,
mode: "async-job",
serviceId: options.serviceId,
environment: options.environment ?? "prod",
job,
statusCommand: `bun scripts/cli.ts job status ${job.id}`,
tailCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`,
note: "User-service CD continues in the background: D601 registry health, commit-pinned artifact check, pull/import, rollout, image-label and live health commit verification.",
rollback: rollbackInfo(spec, target, options.environment ?? "prod", options.commit),
};
}
export function serviceIdFromDeployBackendCoreArgs(action: ArtifactRegistryAction, options: ArtifactRegistryOptions): string | null {
return action === "deploy-backend-core" ? "backend-core" : options.serviceId;
}
export function localHelp(): Record<string, unknown> {
return {
ok: true,
command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service",
output: "json",
usage: [
"bun scripts/cli.ts artifact-registry plan [--provider-id D601]",
"bun scripts/cli.ts artifact-registry render [--provider-id D601]",
"bun scripts/cli.ts artifact-registry status [--provider-id D601]",
"bun scripts/cli.ts artifact-registry health [--provider-id D601]",
"bun scripts/cli.ts artifact-registry install [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --service baidu-netdisk --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --service frontend --env prod --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --service frontend --env dev --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service decision-center --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service decision-center --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service project-manager --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service oa-event-flow --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service code-queue-mgr --commit <full-sha> --dry-run [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service todo-note --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service todo-note --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service findjob --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service findjob --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service pipeline --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service pipeline --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service met-nonlinear --commit <full-sha> --dry-run [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service met-nonlinear --commit <full-sha> --dry-run [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service k3sctl-adapter --commit <full-sha> --dry-run [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service mdtodo --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service mdtodo --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service claudeqq --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env prod --service claudeqq --commit <full-sha> [--dry-run] [--run-now] [--provider-id D601]",
"bun scripts/cli.ts artifact-registry deploy-service --env dev --service code-queue --commit <full-sha> --dry-run [--provider-id D601]",
],
firstStage: "install now writes the rendered systemd/Compose/config files and starts the registry",
artifactConsumers: {
supportedServices: supportedArtifactConsumerServices,
supportedConsumers: supportedArtifactConsumers(),
unsupportedPolicy: "return structured unsupported; never fall back to legacy maintenance-channel source builds",
prodCommands: [
"bun scripts/cli.ts deploy apply --env prod --service backend-core",
"bun scripts/cli.ts deploy apply --env prod --service baidu-netdisk",
"bun scripts/cli.ts deploy apply --env prod --service frontend",
"bun scripts/cli.ts deploy apply --env prod --service decision-center",
"bun scripts/cli.ts deploy apply --env prod --service project-manager",
"bun scripts/cli.ts deploy apply --env prod --service oa-event-flow",
"bun scripts/cli.ts deploy apply --env prod --service code-queue-mgr --dry-run",
"bun scripts/cli.ts deploy apply --env prod --service todo-note",
"bun scripts/cli.ts deploy apply --env prod --service findjob",
"bun scripts/cli.ts deploy apply --env prod --service pipeline",
"bun scripts/cli.ts deploy apply --env prod --service met-nonlinear --dry-run",
"bun scripts/cli.ts deploy apply --env prod --service k3sctl-adapter --dry-run",
"bun scripts/cli.ts deploy apply --env prod --service mdtodo",
"bun scripts/cli.ts deploy apply --env prod --service claudeqq",
],
devCommands: [
"bun scripts/cli.ts deploy apply --env dev --service frontend",
"bun scripts/cli.ts deploy apply --env dev --service decision-center",
"bun scripts/cli.ts deploy apply --env dev --service project-manager --dry-run",
"bun scripts/cli.ts deploy apply --env dev --service oa-event-flow --dry-run",
"bun scripts/cli.ts deploy apply --env dev --service code-queue-mgr --dry-run",
"bun scripts/cli.ts deploy apply --env dev --service todo-note",
"bun scripts/cli.ts deploy apply --env dev --service findjob --dry-run",
"bun scripts/cli.ts deploy apply --env dev --service pipeline --dry-run",
"bun scripts/cli.ts deploy apply --env dev --service met-nonlinear --dry-run",
"bun scripts/cli.ts deploy apply --env dev --service mdtodo",
"bun scripts/cli.ts deploy apply --env dev --service claudeqq",
"bun scripts/cli.ts deploy apply --env dev --service code-queue --dry-run",
],
devOnlyConsumers: ["code-queue"],
rollbackShape: "rerun the same artifact consumer with a previous commit-pinned image",
},
legacyEntrypoints: {
"deploy-backend-core": {
deprecated: true,
enabled: !legacyDeployBackendCoreDisabled,
replacement: "bun scripts/cli.ts deploy apply --env prod --service backend-core --commit <full-sha>",
},
},
defaults: defaultOptions,
};
}
export async function runArtifactRegistryCommand(args: string[], runtime: ArtifactRegistryCommandRuntime = {}): Promise<unknown> {
const action = args[0];
if (isHelpArg(action)) return localHelp();
if (action !== "plan" && action !== "render" && action !== "status" && action !== "health" && action !== "install" && action !== "deploy-backend-core" && action !== "deploy-service") {
throw new Error("artifact-registry usage: plan|render|status|health|install|deploy-backend-core|deploy-service");
}
const typedAction = action as ArtifactRegistryAction;
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 runReadonlyStatusWithRemoteFallback(options, false, runtime);
if (action === "health") return runReadonlyStatusWithRemoteFallback(options, true, runtime);
if (action === "install") {
return options.dryRun ? installDryRun(options) : install(options);
}
if (action === "deploy-backend-core") {
if (options.commit === null) throw new Error("artifact-registry deploy-backend-core requires --commit <full-sha>");
if (legacyDeployBackendCoreDisabled) return legacyDeployBackendCoreResult(options);
return options.runNow ? await deployBackendCoreNow(options) : deployBackendCoreJob(args, options);
}
if (action === "deploy-service") {
const serviceId = serviceIdFromDeployBackendCoreArgs(typedAction, options);
return options.runNow || options.dryRun ? await deployServiceNow({ ...options, serviceId }) : deployServiceJob(args, { ...options, serviceId });
}
throw new Error("unreachable artifact-registry action");
}
+15
View File
@@ -0,0 +1,15 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. Domain barrel for scripts/src/artifact-registry.ts.
export * from "./types";
export * from "./catalog";
export * from "./options";
export * from "./bundle";
export * from "./status";
export * from "./consumer";
export * from "./remote";
export * from "./readonly";
export * from "./install";
export * from "./runtime-secrets";
export * from "./artifact-probe";
export * from "./compose-deploy";
export * from "./k3s-deploy";
export * from "./entry";
+146
View File
@@ -0,0 +1,146 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. install module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:2117-2231 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactRegistryOptions, RenderedFile, RuntimeSecretPresence, RuntimeSecretRequirement } from "./types";
import { renderBundle, rootExecPrelude, shellQuote } from "./bundle";
import { commandTail } from "./consumer";
import { runReadonlyStatus } from "./readonly";
import { runRemoteScript } from "./remote";
import { plan } from "./status";
export function remoteWriteFileCommand(item: RenderedFile): string {
const encoded = Buffer.from(item.content, "utf8").toString("base64");
const rootOwned = item.path.startsWith("/etc/");
return [
`target=${shellQuote(item.path)}`,
"tmp=$(mktemp /tmp/unidesk-artifact-registry.XXXXXX)",
"trap 'rm -f \"$tmp\"' EXIT",
`printf %s ${shellQuote(encoded)} | base64 -d > "$tmp"`,
`if [ ${rootOwned ? "1" : "0"} = 1 ]; then`,
" root_exec mkdir -p \"$(dirname \"$target\")\"",
` root_exec install -m ${shellQuote(item.mode)} "$tmp" "$target"`,
"else",
" mkdir -p \"$(dirname \"$target\")\"",
` install -m ${shellQuote(item.mode)} "$tmp" "$target"`,
"fi",
`echo ${shellQuote(`artifact_registry_file_written path=${item.path} sha256=${item.sha256}`)}`,
].join("\n");
}
export function install(options: ArtifactRegistryOptions): Record<string, unknown> {
const bundle = renderBundle(options);
const script = [
"set -euo pipefail",
"command -v docker >/dev/null",
"docker compose version >/dev/null",
"command -v systemctl >/dev/null",
rootExecPrelude(),
`mkdir -p ${shellQuote(bundle.paths.baseDir)} ${shellQuote(bundle.paths.storage)}`,
...bundle.files.map(remoteWriteFileCommand),
"root_exec systemctl daemon-reload",
`root_exec systemctl enable --now ${shellQuote(options.unitName)}`,
"sleep 2",
`curl -fsS http://${options.host}:${options.port}/v2/ >/dev/null`,
].join("\n");
const command = runRemoteScript(options, script, Math.max(options.timeoutMs, 120_000));
const status = runReadonlyStatus(options, true);
return {
ok: command.exitCode === 0 && !command.timedOut && status.ok === true,
dryRun: false,
mutation: true,
providerId: options.providerId,
render: {
paths: bundle.paths,
files: bundle.files.map((item) => ({ path: item.path, mode: item.mode, sha256: item.sha256 })),
},
installCommand: commandTail(command),
health: status,
};
}
export function installDryRun(options: ArtifactRegistryOptions): Record<string, unknown> {
const bundle = renderBundle(options);
return {
ok: true,
dryRun: true,
readonly: true,
mutation: false,
providerId: options.providerId,
plan: plan(options),
render: bundle,
intendedRemoteActions: [
`mkdir -p ${options.baseDir} ${options.storageDir}`,
`write ${bundle.paths.config}`,
`write ${bundle.paths.compose}`,
`write ${bundle.paths.unit}`,
"systemctl daemon-reload",
`systemctl enable --now ${options.unitName}`,
`curl -fsS http://${options.host}:${options.port}/v2/`,
],
note: "Dry run only; no D601 runtime files or services were changed.",
};
}
export function composeLockScript(innerScript: string): string {
const lockDir = rootPath(".state", "locks");
const lockPath = rootPath(".state", "locks", "server-compose.lock");
return [
"set -euo pipefail",
`mkdir -p ${shellQuote(lockDir)}`,
`echo ${shellQuote(`compose_lock_wait ${lockPath}`)}`,
`flock ${shellQuote(lockPath)} bash -lc ${shellQuote(innerScript)}`,
].join("; ");
}
export function findEnvValueLength(raw: string, key: string): number {
for (const line of raw.split(/\r?\n/u)) {
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const index = line.indexOf("=");
if (index <= 0) continue;
if (line.slice(0, index) !== key) continue;
let value = line.slice(index + 1);
if (value.startsWith("\"") && value.endsWith("\"")) {
try {
value = JSON.parse(value) as string;
} catch {
value = value.slice(1, -1);
}
}
return value.length;
}
return 0;
}
export function runtimeSecretPresenceFromEnvText(envText: string, requirements: RuntimeSecretRequirement[]): RuntimeSecretPresence[] {
return requirements.map((requirement) => {
const length = findEnvValueLength(envText, requirement.sourceEnvName);
return {
sourceEnvName: requirement.sourceEnvName,
containerEnvName: requirement.containerEnvName,
present: length > 0,
length,
};
});
}
+325
View File
@@ -0,0 +1,325 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. k3s-deploy module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:3154-3447 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerSpec, ArtifactConsumerTarget, ArtifactDeployEnvironment, ArtifactRegistryOptions } from "./types";
import { d601DevFrontendAuthPatchScript, registryArtifactMissingMessage, registryArtifactProbeScript } from "./artifact-probe";
import { d601K3sGuardScript, rootExecPrelude, shellQuote } from "./bundle";
import { artifactImageRef, commandTail, deployRefFor, sourceRepoFor } from "./consumer";
import { runReadonlyStatus } from "./readonly";
import { runRemoteScript, runRemoteScriptBackground } from "./remote";
export function rollbackInfo(spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, environment: ArtifactDeployEnvironment, commit: string): Record<string, unknown> {
if (spec.kind === "compose" || spec.kind === "d601-compose") {
const compose = target.compose;
return {
type: spec.kind === "d601-compose" ? "d601-compose-retag-recreate" : "compose-retag-recreate",
composeService: compose?.serviceName,
containerName: compose?.containerName,
workDir: compose?.workDir,
composeFile: compose?.composeFile,
previousImageHint: `Use docker image ls / docker inspect to find the previous labeled ${spec.serviceId} image id; Compose volumes are unchanged.`,
commandShape: `bun scripts/cli.ts deploy apply --env ${environment} --service ${spec.serviceId}${environment === "prod" ? " --commit <previous-full-sha>" : ""}`,
};
}
return {
type: "d601-k3s-previous-commit",
serviceId: spec.serviceId,
environment,
currentCommit: commit,
discovery: `KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n ${target.k3s?.namespace} rollout history deployment/${target.k3s?.deploymentName} && KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n ${target.k3s?.namespace} get deployment ${target.k3s?.deploymentName} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-previous-commit}'`,
commandShape: `bun scripts/cli.ts deploy apply --env ${environment} --service ${spec.serviceId} --commit <previous-full-sha>`,
note: "Rollback is exposed as the same artifact consumer pointed at a previous commit-pinned image that still exists in D601 registry.",
};
}
export function d601K3sArtifactDeployScript(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget, commit: string): string {
const environment = options.environment ?? "prod";
if (target.k3s === undefined) throw new Error(`${spec.serviceId} missing k3s artifact consumer config for ${environment}`);
const manifestText = readFileSync(rootPath(target.k3s.manifestRepoPath), "utf8");
const manifestBase64 = Buffer.from(manifestText, "utf8").toString("base64");
const sourceImage = artifactImageRef(options, spec, commit);
const sourceRepo = sourceRepoFor(options, spec);
const commitImage = target.targetCommitImage(commit);
const k3s = target.k3s;
const deploymentSpecs = [
{ name: k3s.deploymentName, containerName: k3s.containerName },
...(k3s.extraDeployments ?? []).map((deployment) => typeof deployment === "string"
? { name: deployment, containerName: k3s.containerName }
: { name: deployment.name, containerName: deployment.containerName ?? k3s.containerName }),
];
const deploymentSpecsBase64 = Buffer.from(JSON.stringify(deploymentSpecs), "utf8").toString("base64");
const labels = [
`unidesk.ai/deploy-service-id=${spec.serviceId}`,
`unidesk.ai/deploy-ref=${deployRefFor(options, spec)}`,
`unidesk.ai/deploy-repo=${sourceRepo}`,
`unidesk.ai/deploy-commit=${commit}`,
`unidesk.ai/deploy-requested-commit=${commit}`,
`unidesk.ai/image-source=${sourceImage}`,
`unidesk.ai/deploy-environment=${environment}`,
];
return [
"set -euo pipefail",
rootExecPrelude(),
`registry_image=${shellQuote(sourceImage)}`,
`stable_image=${shellQuote(target.targetImage)}`,
`commit_image=${shellQuote(commitImage)}`,
`environment=${shellQuote(environment)}`,
`service_id=${shellQuote(spec.serviceId)}`,
`source_repo=${shellQuote(sourceRepo)}`,
`deploy_ref=${shellQuote(deployRefFor(options, spec))}`,
`commit=${shellQuote(commit)}`,
`dockerfile=${shellQuote(spec.dockerfile)}`,
`namespace=${shellQuote(k3s.namespace)}`,
`deployment=${shellQuote(k3s.deploymentName)}`,
`container_name=${shellQuote(k3s.containerName)}`,
`deployment_specs_b64=${shellQuote(deploymentSpecsBase64)}`,
`service_name=${shellQuote(k3s.serviceName)}`,
`service_port=${shellQuote(String(k3s.servicePort))}`,
`health_path=${shellQuote(k3s.healthPath)}`,
`apply_selector=${shellQuote(k3s.applySelector ?? "")}`,
`pod_selector=${shellQuote(k3s.podLabelSelector ?? `app.kubernetes.io/name=${k3s.deploymentName}`)}`,
"archive=",
"manifest=",
"deployment_specs=",
"health_tmp=",
`manifest_repo_path=${shellQuote(k3s.manifestRepoPath)}`,
`manifest_b64=${shellQuote(manifestBase64)}`,
"export DOCKER_CONFIG=$(mktemp -d /tmp/unidesk-artifact-docker-config.XXXXXX)",
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
"cleanup_artifact_cd() {",
" [ -n \"${archive:-}\" ] && rm -f \"$archive\"",
" [ -n \"${manifest:-}\" ] && rm -f \"$manifest\"",
" [ -n \"${deployment_specs:-}\" ] && rm -f \"$deployment_specs\"",
" [ -n \"${health_tmp:-}\" ] && rm -f \"$health_tmp\"",
" [ -n \"${DOCKER_CONFIG:-}\" ] && rm -rf \"$DOCKER_CONFIG\"",
"}",
"trap cleanup_artifact_cd EXIT",
"command -v docker >/dev/null",
"command -v kubectl >/dev/null",
"command -v ctr >/dev/null",
"test -S /run/k3s/containerd/containerd.sock",
d601K3sGuardScript(),
`curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' ${shellQuote(`http://127.0.0.1:${options.port}/v2/${spec.registryRepository}/manifests/${commit}`)} >/dev/null`,
"docker pull -q \"$registry_image\" >/dev/null",
"label_commit=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}')",
"label_service=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/service-id\" }}')",
"label_repo=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/source-repo\" }}')",
"label_dockerfile=$(docker image inspect \"$registry_image\" --format '{{ index .Config.Labels \"unidesk.ai/dockerfile\" }}')",
"test \"$label_commit\" = \"$commit\"",
"test \"$label_service\" = \"$service_id\"",
"test \"$label_repo\" = \"$source_repo\"",
"test \"$label_dockerfile\" = \"$dockerfile\"",
"docker tag \"$registry_image\" \"$stable_image\"",
"docker tag \"$registry_image\" \"$commit_image\"",
"archive=$(mktemp /tmp/unidesk-artifact-k3s-image.XXXXXX.tar)",
"manifest=$(mktemp /tmp/unidesk-artifact-k3s-manifest.XXXXXX.yaml)",
"docker save \"$commit_image\" \"$stable_image\" -o \"$archive\"",
"root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images import \"$archive\" >/dev/null",
"containerd_image_ref() {",
" ref=\"$1\"",
" if root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images inspect \"$ref\" >/dev/null 2>&1; then printf '%s\\n' \"$ref\"; return; fi",
" suffix=\"${ref#*/}\"",
" actual_ref=$(root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls -q | grep -F \"/$suffix\" | head -1 || true)",
" test -n \"$actual_ref\"",
" printf '%s\\n' \"$actual_ref\"",
"}",
"stable_containerd_ref=$(containerd_image_ref \"$stable_image\")",
"commit_containerd_ref=$(containerd_image_ref \"$commit_image\")",
"printf '%s' \"$manifest_b64\" | base64 -d > \"$manifest\"",
"deployment_specs=$(mktemp /tmp/unidesk-artifact-k3s-deployments.XXXXXX.json)",
"printf '%s' \"$deployment_specs_b64\" | base64 -d > \"$deployment_specs\"",
"python3 - \"$deployment_specs\" \"$manifest\" <<'PY'",
"import json, sys",
"specs = json.load(open(sys.argv[1], encoding='utf-8'))",
"manifest = open(sys.argv[2], encoding='utf-8').read()",
"missing = [item['name'] for item in specs if f\"name: {item['name']}\" not in manifest]",
"if missing:",
" raise SystemExit('manifest missing deployment(s): ' + ','.join(missing))",
"PY",
"if [ -n \"$apply_selector\" ]; then kubectl apply -f \"$manifest\" -l \"$apply_selector\"; else kubectl apply -f \"$manifest\"; fi",
...(environment === "dev" && spec.serviceId === "frontend" ? [d601DevFrontendAuthPatchScript(readConfig())] : []),
"previous_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}' 2>/dev/null || true)",
"python3 - \"$deployment_specs\" <<'PY' | while IFS=$'\\t' read -r rollout_deployment rollout_container; do",
"import json, sys",
"for item in json.load(open(sys.argv[1], encoding='utf-8')):",
" print(f\"{item['name']}\\t{item['containerName']}\")",
"PY",
" kubectl -n \"$namespace\" set image \"deployment/$rollout_deployment\" \"$rollout_container=$commit_image\"",
" kubectl -n \"$namespace\" set env \"deployment/$rollout_deployment\" \"UNIDESK_DEPLOY_SERVICE_ID=$service_id\" \"UNIDESK_DEPLOY_REF=$deploy_ref\" \"UNIDESK_DEPLOY_REPO=$source_repo\" \"UNIDESK_DEPLOY_COMMIT=$commit\" \"UNIDESK_DEPLOY_REQUESTED_COMMIT=$commit\" \"CODE_QUEUE_DEPLOY_COMMIT=$commit\" \"CODE_QUEUE_DEPLOY_REQUESTED_COMMIT=$commit\"",
` kubectl -n "$namespace" annotate "deployment/$rollout_deployment" ${labels.map(shellQuote).join(" ")} --overwrite`,
" if [ -n \"$previous_commit\" ] && [ \"$previous_commit\" != \"$commit\" ]; then kubectl -n \"$namespace\" annotate \"deployment/$rollout_deployment\" \"unidesk.ai/deploy-previous-commit=$previous_commit\" --overwrite; fi",
"done",
"python3 - \"$deployment_specs\" <<'PY' | while IFS= read -r rollout_deployment; do",
"import json, sys",
"for item in json.load(open(sys.argv[1], encoding='utf-8')):",
" print(item['name'])",
"PY",
" kubectl -n \"$namespace\" rollout status \"deployment/$rollout_deployment\" --timeout=180s",
"done",
"deployment_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}')",
"deployment_requested_commit=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-requested-commit}')",
"test \"$deployment_commit\" = \"$commit\"",
"test \"$deployment_requested_commit\" = \"$commit\"",
"deployment_image=$(kubectl -n \"$namespace\" get deployment \"$deployment\" -o jsonpath='{.spec.template.spec.containers[?(@.name==\"'\"$container_name\"'\")].image}')",
"test \"$deployment_image\" = \"$commit_image\"",
"containerd_config_label() {",
" ref=\"$1\"",
" key=\"$2\"",
" actual_ref=$(containerd_image_ref \"$ref\")",
" target_digest=$(root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images inspect \"$actual_ref\" | python3 -c 'import re,sys; m=re.search(r\"@(sha256:[0-9a-f]+)\", sys.stdin.read()); print(m.group(1) if m else \"\")')",
" test -n \"$target_digest\"",
" digest=\"$target_digest\"",
" config_digest=",
" while [ -n \"$digest\" ]; do",
" content_file=$(mktemp /tmp/unidesk-artifact-content.XXXXXX.json)",
" root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io content get \"$digest\" > \"$content_file\"",
" next=$(python3 - \"$content_file\" <<'PY'",
"import json, sys",
"obj = json.load(open(sys.argv[1], encoding='utf-8'))",
"manifests = obj.get('manifests')",
"if isinstance(manifests, list):",
" for item in manifests:",
" platform = item.get('platform') or {}",
" if platform.get('architecture') in ('amd64', '') or not platform:",
" print('manifest:' + str(item.get('digest') or ''))",
" raise SystemExit(0)",
" print('')",
" raise SystemExit(0)",
"config = obj.get('config') or {}",
"print('config:' + str(config.get('digest') or ''))",
"PY",
" )",
" rm -f \"$content_file\"",
" case \"$next\" in",
" manifest:*) digest=\"${next#manifest:}\" ;;",
" config:*) config_digest=\"${next#config:}\"; break ;;",
" *) digest= ;;",
" esac",
" done",
" test -n \"$config_digest\"",
" config_file=$(mktemp /tmp/unidesk-artifact-config.XXXXXX.json)",
" root_exec ctr --address /run/k3s/containerd/containerd.sock -n k8s.io content get \"$config_digest\" > \"$config_file\"",
" python3 - \"$config_file\" \"$key\" <<'PY'",
"import json, sys",
"cfg = json.load(open(sys.argv[1], encoding='utf-8'))",
"key = sys.argv[2]",
"print(str(((cfg.get('config') or {}).get('Labels') or {}).get(key) or ''))",
"PY",
" rm -f \"$config_file\"",
"}",
"image_label_commit=$(containerd_config_label \"$commit_image\" 'unidesk.ai/source-commit' 2>/tmp/unidesk-artifact-containerd-label.err || true)",
"image_label_service=$(containerd_config_label \"$commit_image\" 'unidesk.ai/service-id' 2>>/tmp/unidesk-artifact-containerd-label.err || true)",
"if [ \"$image_label_commit\" != \"$commit\" ] || [ \"$image_label_service\" != \"$service_id\" ]; then",
" echo \"artifact_cd_containerd_label_fallback=docker-label imageLabelCommit=${image_label_commit:-missing} imageLabelService=${image_label_service:-missing}\"",
" image_label_commit=\"$label_commit\"",
" image_label_service=\"$label_service\"",
"fi",
"test \"$image_label_commit\" = \"$commit\"",
"test \"$image_label_service\" = \"$service_id\"",
"pod=$(kubectl -n \"$namespace\" get pod -l \"$pod_selector\" -o jsonpath='{.items[0].metadata.name}')",
"test -n \"$pod\"",
"pod_image=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.spec.containers[?(@.name==\"'\"$container_name\"'\")].image}')",
"test \"$pod_image\" = \"$commit_image\"",
"pod_image_id=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.containerStatuses[?(@.name==\"'\"$container_name\"'\")].imageID}')",
"if [ -z \"$pod_image_id\" ]; then pod_image_id=$(kubectl -n \"$namespace\" get pod \"$pod\" -o jsonpath='{.status.containerStatuses[0].imageID}'); fi",
"case \"$health_path\" in /*) ;; *) health_path=\"/$health_path\" ;; esac",
"proxy_path=\"/api/v1/namespaces/$namespace/services/http:$service_name:$service_port/proxy$health_path\"",
"health_tmp=$(mktemp /tmp/unidesk-artifact-health.XXXXXX.json)",
"for attempt in $(seq 1 60); do",
" if kubectl get --raw \"$proxy_path\" > \"$health_tmp\" 2>/tmp/unidesk-artifact-health.err; then",
" health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_tmp\" 2>/dev/null || true)",
" health_requested_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"requestedCommit\") or \"\"))' \"$health_tmp\" 2>/dev/null || true)",
" health_ok=$(python3 -c 'import json,sys; print(\"true\" if json.load(open(sys.argv[1])).get(\"ok\") is True else \"false\")' \"$health_tmp\" 2>/dev/null || true)",
" echo \"artifact_cd_health_probe attempt=$attempt ok=$health_ok commit=$health_commit requestedCommit=$health_requested_commit\"",
" if [ \"$health_ok\" = \"true\" ] && [ \"$health_commit\" = \"$commit\" ] && [ \"$health_requested_commit\" = \"$commit\" ]; then break; fi",
" else",
" echo \"artifact_cd_health_probe attempt=$attempt request=failed\"",
" fi",
" if [ \"$attempt\" = \"60\" ]; then echo artifact_cd_health_failed >&2; cat \"$health_tmp\" >&2 || true; exit 1; fi",
" sleep 2",
"done",
"printf 'artifact_cd_service=%s\\nartifact_cd_environment=%s\\nartifact_cd_manifest_repo_path=%s\\nartifact_cd_source_image=%s\\nartifact_cd_stable_image=%s\\nartifact_cd_runtime_image=%s\\nartifact_cd_commit=%s\\nartifact_cd_requested_commit=%s\\nartifact_cd_previous_commit=%s\\nartifact_cd_pod=%s\\nartifact_cd_pod_image=%s\\nartifact_cd_pod_image_id=%s\\nartifact_cd_image_label_commit=%s\\nartifact_cd_health_commit=%s\\nartifact_cd_health_requested_commit=%s\\n' \"$service_id\" \"$environment\" \"$manifest_repo_path\" \"$registry_image\" \"$stable_image\" \"$commit_image\" \"$commit\" \"$deployment_requested_commit\" \"$previous_commit\" \"$pod\" \"$pod_image\" \"$pod_image_id\" \"$image_label_commit\" \"$health_commit\" \"$health_requested_commit\"",
].join("\n");
}
export async function deployD601K3sArtifactNow(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec, target: ArtifactConsumerTarget): Promise<Record<string, unknown>> {
const environment = options.environment ?? "prod";
const commit = options.commit;
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
const health = runReadonlyStatus(options, true);
if (health.ok !== true) return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
const sourceImage = artifactImageRef(options, spec, commit);
const registryProbe = runRemoteScript(options, registryArtifactProbeScript(options, spec, commit), Math.max(options.timeoutMs, 120_000));
if (registryProbe.exitCode !== 0 || registryProbe.timedOut) {
return {
ok: false,
supported: true,
serviceId: spec.serviceId,
step: "registry-artifact-check",
error: registryArtifactMissingMessage(spec),
sourceImage,
registryProbe: commandTail(registryProbe),
};
}
const deployScript = d601K3sArtifactDeployScript(options, spec, target, commit);
const deploy = await runRemoteScriptBackground(options, deployScript, Math.max(options.timeoutMs, 420_000), "d601-k3s-deploy");
if (deploy.exitCode !== 0 || deploy.timedOut) {
return {
ok: false,
supported: true,
serviceId: spec.serviceId,
step: "d601-k3s-artifact-deploy",
sourceImage,
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
rollback: rollbackInfo(spec, target, environment, commit),
};
}
return {
ok: true,
supported: true,
serviceId: spec.serviceId,
environment,
commit,
providerId: options.providerId,
sourceRepo: sourceRepoFor(options, spec),
deployRef: deployRefFor(options, spec),
sourceImage,
stableImage: target.targetImage,
runtimeImage: target.targetCommitImage(commit),
manifestRepoPath: target.k3s?.manifestRepoPath,
registryProbe: commandTail(registryProbe),
deploy: commandTail(deploy),
validation: {
liveCommit: commit,
liveRequestedCommit: commit,
imageLabelCommit: commit,
serviceHealthCommit: commit,
serviceHealthRequestedCommit: commit,
healthyOldVersionAccepted: false,
},
rollback: rollbackInfo(spec, target, environment, commit),
};
}
+125
View File
@@ -0,0 +1,125 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:881-978 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactDeployEnvironment, ArtifactRegistryOptions } from "./types";
import { defaultOptions } from "./types";
export function isHelpArg(value: string | undefined): boolean {
return value === undefined || value === "help" || value === "--help" || value === "-h";
}
export function requireValue(args: string[], index: number, option: string): string {
const value = args[index + 1];
if (value === undefined || value.length === 0) throw new Error(`${option} requires a non-empty value`);
return value;
}
export function positiveInt(value: string, option: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
return parsed;
}
export function absolutePath(value: string, option: string): string {
if (!value.startsWith("/")) throw new Error(`${option} must be an absolute path`);
if (value.includes("\n") || value.includes("\0")) throw new Error(`${option} must not contain control characters`);
return value.replace(/\/+$/u, "");
}
export function commitValue(value: string, option: string): string {
const normalized = value.toLowerCase();
if (!/^[0-9a-f]{40}$/u.test(normalized)) throw new Error(`${option} must be a full 40-character commit SHA`);
return normalized;
}
export function environmentValue(value: string, option: string): ArtifactDeployEnvironment {
if (value === "prod" || value === "dev") return value;
throw new Error(`${option} must be one of: prod, dev`);
}
export function parseArtifactRegistryOptions(args: string[]): ArtifactRegistryOptions {
const options = { ...defaultOptions };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--run-now") {
options.runNow = true;
} else if (arg === "--env" || arg === "--environment") {
const environment = requireValue(args, index, arg);
if (environment !== "dev" && environment !== "prod") throw new Error(`${arg} must be dev or prod`);
options.environment = environment;
index += 1;
} else if (arg === "--provider-id") {
options.providerId = requireValue(args, index, arg);
index += 1;
} else if (arg === "--host") {
options.host = requireValue(args, index, arg);
index += 1;
} else if (arg === "--port") {
options.port = positiveInt(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--image") {
options.image = requireValue(args, index, arg);
index += 1;
} else if (arg === "--base-dir") {
options.baseDir = absolutePath(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--storage-dir") {
options.storageDir = absolutePath(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--timeout-ms") {
options.timeoutMs = positiveInt(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--commit") {
options.commit = commitValue(requireValue(args, index, arg), arg);
index += 1;
} else if (arg === "--target-image") {
options.targetImage = requireValue(args, index, arg);
index += 1;
} else if (arg === "--service" || arg === "--service-id") {
options.serviceId = requireValue(args, index, arg);
index += 1;
} else if (arg === "--source-repo") {
options.sourceRepo = requireValue(args, index, arg);
options.sourceRepoExplicit = true;
index += 1;
} else if (arg === "--deploy-ref") {
options.deployRef = requireValue(args, index, arg);
index += 1;
} else if (arg === "--deploy-json-service") {
options.deployJsonService = parseDeployJsonServiceContractBase64(requireValue(args, index, arg));
index += 1;
} else if (arg === "--env" || arg === "--environment") {
options.environment = environmentValue(requireValue(args, index, arg), arg);
index += 1;
} else {
throw new Error(`unknown artifact-registry option: ${arg}`);
}
}
if (options.host !== "127.0.0.1") throw new Error("--host is first-stage restricted to 127.0.0.1");
if (options.port !== 5000) throw new Error("--port is first-stage restricted to 5000");
return options;
}
+213
View File
@@ -0,0 +1,213 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. readonly module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:1934-2116 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactRegistryCommandRuntime, ArtifactRegistryOptions, ArtifactRegistryReadonlyProbe } from "./types";
import { renderBundle } from "./bundle";
import { commandTail } from "./consumer";
import { annotateRemoteFirstReadonlyResult, annotateRemoteReadonlyResult, controlPlaneMissingResult, parsedCliData, readonlyCommandFailureResult, remoteFrontendHostFromEnv, runRemoteScript, unwrapRemoteArtifactRegistryResult } from "./remote";
import { asBool, parseKeyValueOutput, readonlyRemoteCommandShape, registryHealthDecision, statusScript } from "./status";
export function artifactRegistryReadonlyAutoRemotePlan(
action: "status" | "health",
options: ArtifactRegistryOptions,
env: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
const remoteHost = remoteFrontendHostFromEnv(env);
return {
enabled: remoteHost !== null,
action,
providerId: options.providerId,
host: remoteHost,
transport: "frontend",
command: remoteHost === null ? null : `bun scripts/cli.ts --main-server-ip ${remoteHost} artifact-registry ${action} --provider-id ${options.providerId}`,
failureClassification: remoteHost === null ? "control-plane-missing" : null,
};
}
export function runReadonlyStatusWithRemoteFallback(options: ArtifactRegistryOptions, healthMode: boolean, runtime: ArtifactRegistryCommandRuntime = {}): Record<string, unknown> {
const action = healthMode ? "health" : "status";
const remoteHost = remoteFrontendHostFromEnv(runtime.env ?? process.env);
const shouldPreferRemote = remoteHost !== null && (
Boolean((runtime.env ?? process.env).CODE_QUEUE_SERVICE_ROLE)
|| Boolean((runtime.env ?? process.env).CODE_QUEUE_INSTANCE_ID)
|| Boolean((runtime.env ?? process.env).KUBERNETES_SERVICE_HOST)
|| Boolean((runtime.env ?? process.env).UNIDESK_ARTIFACT_REGISTRY_REMOTE_FIRST)
);
if (shouldPreferRemote) {
const remoteResult = runReadonlyStatusViaRemoteFrontend(options, action, remoteHost, null, runtime);
if (remoteResult !== null) return remoteResult;
}
const localResult = runReadonlyStatus(options, healthMode, runtime);
if (localResult.failureClassification !== "local-docker-required") return localResult;
if (remoteHost === null) return controlPlaneMissingResult(options, action, localResult, null, null, remoteHost);
const remoteResult = runReadonlyStatusViaRemoteFrontend(options, action, remoteHost, localResult, runtime);
if (remoteResult !== null) return remoteResult;
return controlPlaneMissingResult(options, action, localResult, null, null, remoteHost);
}
export function runReadonlyStatusViaRemoteFrontend(
options: ArtifactRegistryOptions,
action: "status" | "health",
remoteHost: string,
localResult: Record<string, unknown> | null,
runtime: ArtifactRegistryCommandRuntime,
): Record<string, unknown> | null {
const remoteCommandArgs = [
process.execPath,
rootPath("scripts", "cli.ts"),
"--main-server-ip",
remoteHost,
"artifact-registry",
action,
"--provider-id",
options.providerId,
"--timeout-ms",
String(options.timeoutMs),
];
const remoteTimeoutMs = Math.max(options.timeoutMs + 15_000, 45_000);
const remoteCommand = runtime.runCliForTest === undefined
? runCommand(remoteCommandArgs, repoRoot, { timeoutMs: remoteTimeoutMs })
: runtime.runCliForTest(remoteCommandArgs, remoteTimeoutMs);
const parsed = parsedCliData(remoteCommand.stdout);
const remoteResult = parsed === null ? null : unwrapRemoteArtifactRegistryResult(parsed);
if (remoteCommand.exitCode === 0 && !remoteCommand.timedOut && remoteResult !== null) {
return localResult === null
? annotateRemoteFirstReadonlyResult(remoteResult, remoteHost, remoteCommand)
: annotateRemoteReadonlyResult(remoteResult, localResult, remoteHost, remoteCommand);
}
if (localResult !== null) return controlPlaneMissingResult(options, action, localResult, remoteResult, remoteCommand, remoteHost);
return null;
}
export function statusFromValues(options: ArtifactRegistryOptions, values: Record<string, string>, command: CommandResult, healthMode: boolean): Record<string, unknown> {
const commandOk = command.exitCode === 0 && !command.timedOut;
const bundle = renderBundle(options);
const hashes = Object.fromEntries(bundle.files.map((item) => [item.path, item.sha256]));
const checks = {
systemctlAvailable: asBool(values.systemctl_available),
dockerAvailable: asBool(values.docker_available),
curlAvailable: asBool(values.curl_available),
unitExists: asBool(values.unit_exists),
unitActive: values.unit_active === "active",
unitEnabled: values.unit_enabled === "enabled",
composeExists: asBool(values.compose_exists),
configExists: asBool(values.config_exists),
storageExists: asBool(values.storage_exists),
containerRunning: asBool(values.container_running),
loopbackOnly: asBool(values.loopback_only),
v2Ok: values.v2_http_code === "200",
imageMatches: asBool(values.image_matches),
configHashMatches: asBool(values.config_hash_matches),
composeHashMatches: asBool(values.compose_hash_matches),
unitHashMatches: asBool(values.unit_hash_matches),
};
const installed = checks.unitExists || checks.composeExists || checks.configExists || checks.storageExists || checks.containerRunning;
const healthy = commandOk
&& checks.systemctlAvailable
&& checks.dockerAvailable
&& checks.unitExists
&& checks.unitActive
&& checks.composeExists
&& checks.configExists
&& checks.storageExists
&& checks.containerRunning
&& checks.loopbackOnly
&& checks.v2Ok
&& checks.imageMatches
&& checks.configHashMatches
&& checks.composeHashMatches
&& checks.unitHashMatches;
const decision = registryHealthDecision(checks, commandOk);
return {
ok: healthMode ? healthy : commandOk,
readonly: true,
installed,
healthy,
...decision,
remoteCommandShape: readonlyRemoteCommandShape(healthMode ? "health" : "status", options),
checks,
observed: {
unit: { path: bundle.paths.unit, active: values.unit_active, enabled: values.unit_enabled },
compose: { path: bundle.paths.compose, sha256: values.compose_hash ?? null },
config: { path: bundle.paths.config, sha256: values.config_hash ?? null },
storage: { path: bundle.paths.storage },
container: {
name: options.containerName,
running: values.container_running,
status: values.container_status,
image: values.container_image,
restartPolicy: values.container_restart_policy,
},
listener: {
count: Number(values.listener_count ?? 0),
badCount: Number(values.bad_listener_count ?? 0),
loopbackOnly: checks.loopbackOnly,
},
registryApi: { v2HttpCode: values.v2_http_code },
},
expected: {
unitHash: hashes[bundle.paths.unit] ?? "",
composeHash: hashes[bundle.paths.compose] ?? "",
configHash: hashes[bundle.paths.config] ?? "",
image: options.image,
endpoint: `http://${options.host}:${options.port}`,
},
command: commandTail(command),
};
}
export function runReadonlyStatus(options: ArtifactRegistryOptions, healthMode: boolean, runtime: ArtifactRegistryCommandRuntime = {}): Record<string, unknown> {
const bundle = renderBundle(options);
const script = statusScript(options, bundle);
const result = runRemoteScript(options, script, options.timeoutMs, runtime);
if (result.exitCode !== 0 || result.timedOut) {
return readonlyCommandFailureResult(options, result, healthMode ? "health" : "status");
}
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,
remoteCommandShape: readonlyRemoteCommandShape(action, options),
};
}
export function artifactRegistryReadonlyResultFromCommand(
probe: ArtifactRegistryReadonlyProbe,
command: CommandResult,
): Record<string, unknown> {
if (command.exitCode !== 0 || command.timedOut) {
return readonlyCommandFailureResult(probe.options, command, probe.action);
}
return statusFromValues(probe.options, parseKeyValueOutput(command.stdout), command, probe.healthMode);
}
+304
View File
@@ -0,0 +1,304 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:1659-1933 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactRegistryCommandRuntime, ArtifactRegistryOptions } from "./types";
import { renderBundle, safeName, shellQuote } from "./bundle";
import { artifactRegistryCommandTail } from "./consumer";
import { classifyProviderSshCommandFailure, isLocalProvider, providerSshCommandFailureScopes, readonlyRemoteCommandShape, registryRecommendedAction } from "./status";
export function runRemoteScript(options: ArtifactRegistryOptions, script: string, timeoutMs = options.timeoutMs, runtime: ArtifactRegistryCommandRuntime = {}): CommandResult {
if (runtime.runRemoteScriptForTest !== undefined) return runtime.runRemoteScriptForTest(options, script, timeoutMs);
if (isLocalProvider(options.providerId)) {
return runCommand(["bash", "-lc", script], repoRoot, { timeoutMs });
}
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
return runCommand(command, repoRoot, { timeoutMs });
}
export function combineCommandResults(command: string[], parts: CommandResult[]): CommandResult {
const failed = parts.find((part) => part.exitCode !== 0 || part.timedOut) ?? parts.at(-1) ?? null;
return {
command,
cwd: repoRoot,
exitCode: failed === null ? 0 : failed.exitCode,
signal: failed?.signal ?? null,
timedOut: parts.some((part) => part.timedOut),
stdout: parts.map((part) => part.stdout).filter((part) => part.length > 0).join("\n"),
stderr: parts.map((part) => part.stderr).filter((part) => part.length > 0).join("\n"),
};
}
export function downloadRemoteFile(options: ArtifactRegistryOptions, remotePath: string, localPath: string, timeoutMs = options.timeoutMs): CommandResult {
const transferRuntimeTimeoutMs = Math.max(timeoutMs * 4, 60 * 60_000);
const command = [
process.execPath,
"scripts/cli.ts",
"ssh",
options.providerId,
"download",
"--inactivity-timeout-ms",
String(timeoutMs),
remotePath,
localPath,
];
const stdoutFile = process.env.UNIDESK_JOB_STDOUT_FILE;
const stderrFile = process.env.UNIDESK_JOB_STDERR_FILE;
if (!stdoutFile || !stderrFile) return runCommand(command, repoRoot, { timeoutMs: transferRuntimeTimeoutMs });
return runCommand([
"bash",
"-lc",
`set -o pipefail; "$@" > >(tee -a ${shellQuote(stdoutFile)}) 2> >(tee -a ${shellQuote(stderrFile)} >&2)`,
"unidesk-artifact-download",
...command,
], repoRoot, { timeoutMs: transferRuntimeTimeoutMs });
}
export async function runRemoteScriptBackground(
options: ArtifactRegistryOptions,
script: string,
timeoutMs: number,
name: string,
): Promise<CommandResult> {
const token = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
const prefix = `/tmp/unidesk-artifact-${safeName(options.serviceId ?? "service")}-${safeName(name)}-${token}`;
const logFile = `${prefix}.log`;
const doneFile = `${prefix}.done`;
const wrapped = [
`bash -lc ${shellQuote(script)}`,
"code=$?",
`printf '%s\\n' "$code" > ${shellQuote(doneFile)}`,
"exit \"$code\"",
].join("; ");
const launchScript = [
`rm -f ${shellQuote(logFile)} ${shellQuote(doneFile)}`,
`nohup bash -lc ${shellQuote(wrapped)} > ${shellQuote(logFile)} 2>&1 < /dev/null & echo $!`,
].join("\n");
const launch = runRemoteScript(options, launchScript, 30_000);
const pid = launch.stdout.trim().split(/\n/u).pop()?.trim() ?? "";
if (launch.exitCode !== 0 || launch.timedOut || !/^\d+$/u.test(pid)) {
return combineCommandResults(["artifact-registry", "remote-background", name], [launch]);
}
const startedAt = Date.now();
let latest: CommandResult | null = null;
while (Date.now() - startedAt < timeoutMs) {
await Bun.sleep(2_000);
const poll = runRemoteScript(options, [
`if [ -f ${shellQuote(doneFile)} ]; then printf 'SENTINEL:%s\\n' "$(cat ${shellQuote(doneFile)} 2>/dev/null || true)"; else echo RUNNING; fi`,
`tail -n 160 ${shellQuote(logFile)} 2>/dev/null | tr -d '\\000' | LC_ALL=C sed 's/[^[:print:]\t]//g' || true`,
].join("; "), 30_000);
latest = poll;
if (poll.exitCode !== 0 || poll.timedOut) continue;
const stdout = poll.stdout.trimEnd();
const [head = "", ...rest] = stdout.split("\n");
if (!head.startsWith("SENTINEL:")) continue;
const code = Number(head.slice("SENTINEL:".length).trim());
return {
command: ["artifact-registry", "remote-background", name],
cwd: repoRoot,
exitCode: Number.isInteger(code) ? code : 1,
signal: null,
timedOut: false,
stdout: [
`remote_background_name=${name}`,
`remote_background_pid=${pid}`,
`remote_background_log=${logFile}`,
...rest,
].join("\n"),
stderr: poll.stderr,
};
}
return {
command: ["artifact-registry", "remote-background", name],
cwd: repoRoot,
exitCode: 124,
signal: null,
timedOut: true,
stdout: [
`remote_background_name=${name}`,
`remote_background_pid=${pid}`,
`remote_background_log=${logFile}`,
latest?.stdout ?? "",
].join("\n"),
stderr: latest?.stderr ?? `remote background script ${name} did not finish within ${timeoutMs}ms`,
};
}
export function readonlyCommandFailureResult(
options: ArtifactRegistryOptions,
command: CommandResult,
action: "status" | "health",
): Record<string, unknown> {
const bundle = renderBundle(options);
const failureClassification = classifyProviderSshCommandFailure(command);
return {
ok: false,
readonly: true,
installed: false,
healthy: false,
decision: failureClassification === "local-docker-required" ? "infra-blocked" : "retryable-transient",
retryable: failureClassification !== "ssh-helper-command-shape-incompatible",
failureClassification,
recommendedAction: registryRecommendedAction(failureClassification),
remoteCommandShape: readonlyRemoteCommandShape(action, options),
healthyScopes: [],
failedScopes: providerSshCommandFailureScopes(failureClassification),
runtimeApiHealthy: false,
checks: {},
expected: {
endpoint: `http://${options.host}:${options.port}`,
image: options.image,
paths: bundle.paths,
},
command: artifactRegistryCommandTail(command),
};
}
export function remoteFrontendHostFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
for (const key of ["UNIDESK_MAIN_SERVER_IP", "UNIDESK_MAIN_SERVER_HOST", "CODE_QUEUE_DEV_CONTAINER_MASTER_HOST"]) {
const value = env[key]?.trim() ?? "";
if (value.length === 0) continue;
if (value === "localhost" || value === "127.0.0.1" || value === "::1") continue;
return value.replace(/\/+$/u, "");
}
if (env.CODE_QUEUE_SERVICE_ROLE || env.CODE_QUEUE_INSTANCE_ID || env.KUBERNETES_SERVICE_HOST) {
const publicHost = readConfig().network.publicHost.trim();
if (publicHost.length > 0 && publicHost !== "localhost" && publicHost !== "127.0.0.1" && publicHost !== "::1") return publicHost;
}
return null;
}
export function controlPlaneMissingResult(
options: ArtifactRegistryOptions,
action: "status" | "health",
localResult: Record<string, unknown>,
remoteResult: Record<string, unknown> | null,
remoteCommand: CommandResult | null,
remoteHost: string | null,
): Record<string, unknown> {
const bundle = renderBundle(options);
return {
ok: false,
readonly: true,
installed: false,
healthy: false,
decision: "infra-blocked",
retryable: true,
runnerDisposition: "infra-blocked",
failureClassification: "control-plane-missing",
recommendedAction: registryRecommendedAction("control-plane-missing"),
remoteCommandShape: readonlyRemoteCommandShape(action, options),
healthyScopes: [],
failedScopes: providerSshCommandFailureScopes("control-plane-missing"),
runtimeApiHealthy: false,
checks: {},
expected: {
endpoint: `http://${options.host}:${options.port}`,
image: options.image,
paths: bundle.paths,
},
controlPlane: {
preferred: "remote-frontend",
remoteFallbackAttempted: remoteHost !== null,
remoteFallbackUsed: false,
remoteHost,
localBackendCoreMissing: true,
classification: "control-plane-missing",
retryCommand: remoteHost === null
? `bun scripts/cli.ts --main-server-ip <host> artifact-registry ${action} --provider-id ${options.providerId}`
: `bun scripts/cli.ts --main-server-ip ${remoteHost} artifact-registry ${action} --provider-id ${options.providerId}`,
},
localObservation: localResult,
remoteObservation: remoteResult,
remoteCommand: remoteCommand === null ? null : artifactRegistryCommandTail(remoteCommand),
};
}
export function parsedCliData(stdout: string): Record<string, unknown> | null {
if (stdout.trim().length === 0) return null;
try {
const parsed = JSON.parse(stdout) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
const record = parsed as Record<string, unknown>;
const data = record.data;
return typeof data === "object" && data !== null && !Array.isArray(data) ? data as Record<string, unknown> : record;
} catch {
return null;
}
}
export function unwrapRemoteArtifactRegistryResult(data: Record<string, unknown>): Record<string, unknown> | null {
const directResult = data.result;
if (typeof directResult === "object" && directResult !== null && !Array.isArray(directResult)) return directResult as Record<string, unknown>;
const nested = data.data;
if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
const nestedRecord = nested as Record<string, unknown>;
const nestedResult = nestedRecord.result;
if (typeof nestedResult === "object" && nestedResult !== null && !Array.isArray(nestedResult)) return nestedResult as Record<string, unknown>;
}
return null;
}
export function annotateRemoteReadonlyResult(
result: Record<string, unknown>,
localResult: Record<string, unknown>,
remoteHost: string,
command: CommandResult,
): Record<string, unknown> {
return {
...result,
controlPlane: {
preferred: "remote-frontend",
remoteFallbackAttempted: true,
remoteFallbackUsed: true,
remoteHost,
localBackendCoreMissing: true,
classification: null,
},
localObservation: localResult,
remoteCommand: artifactRegistryCommandTail(command),
};
}
export function annotateRemoteFirstReadonlyResult(
result: Record<string, unknown>,
remoteHost: string,
command: CommandResult,
): Record<string, unknown> {
return {
...result,
controlPlane: {
preferred: "remote-frontend",
remoteFallbackAttempted: false,
remoteFallbackUsed: true,
remoteFirst: true,
remoteHost,
localBackendCoreMissing: null,
classification: null,
},
remoteCommand: artifactRegistryCommandTail(command),
};
}
@@ -0,0 +1,228 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. runtime-secrets module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:2232-2390 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerTarget, ArtifactDeployEnvironment, ArtifactRegistryOptions, AuthHealthGate, ComposeArtifactRuntime, RuntimeSecretContract, RuntimeSecretPresence, RuntimeSecretRequirement, RuntimeSecretSource } from "./types";
import { composeFile, safeName, shellQuote } from "./bundle";
import { baiduNetdiskAuthHealthGate } from "./catalog";
import { runtimeSecretPresenceFromEnvText } from "./install";
import { combineCommandResults, downloadRemoteFile, runRemoteScript, runRemoteScriptBackground } from "./remote";
export function baiduNetdiskAuthHealthGateStatus(health: unknown): { ok: boolean; requiredFields: string[]; failedFields: string[] } {
const record = typeof health === "object" && health !== null && !Array.isArray(health) ? health as Record<string, unknown> : {};
const auth = typeof record.auth === "object" && record.auth !== null && !Array.isArray(record.auth) ? record.auth as Record<string, unknown> : {};
const failedFields = baiduNetdiskAuthHealthGate.requiredFields.filter((field) => auth[field] !== true);
return {
ok: failedFields.length === 0,
requiredFields: baiduNetdiskAuthHealthGate.requiredFields,
failedFields,
};
}
export function composeArtifactRuntime(config: UniDeskConfig, target: ArtifactConsumerTarget): ComposeArtifactRuntime {
if (target.compose === undefined) throw new Error("compose artifact target missing compose config");
const compose = target.compose;
const workDir = compose.workDir ?? config.providerGateway.upgrade.hostProjectRoot;
const composeFile = compose.composeFile ?? config.providerGateway.upgrade.composeFile;
const composeEnvFile = config.providerGateway.upgrade.composeEnvFile;
const envFile = join(workDir, composeEnvFile);
const project = compose.projectHint ?? (config.providerGateway.upgrade.composeProject || config.docker.projectName);
const command = ["docker", "compose", "--env-file", envFile, "-f", join(workDir, composeFile), "-p", project];
return { workDir, composeFile, composeEnvFile, envFile, project, command };
}
export function runtimeSecretSourceForTarget(config: UniDeskConfig, target: ArtifactConsumerTarget): RuntimeSecretSource {
const runtime = composeArtifactRuntime(config, target);
return {
kind: "compose-env-file",
path: runtime.envFile,
exists: existsSync(runtime.envFile),
configSource: "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile",
workDir: runtime.workDir,
composeEnvFile: runtime.composeEnvFile,
composeService: target.compose?.serviceName ?? null,
containerName: target.compose?.containerName ?? null,
valuesPrinted: false,
};
}
export function runtimeSecretRecommendedAction(missing: string[]): string {
if (missing.length === 0) return "none";
return "Restore the missing source env keys in the canonical Compose env file without printing values, then rerun deploy apply --env <env> --service <service> --dry-run before any live apply.";
}
export function runtimeSecretRecommendedActionForService(contract: RuntimeSecretContract, environment: ArtifactDeployEnvironment, serviceId: string): RuntimeSecretContract {
if (contract.requiredSecretsPresent) return { ...contract, recommendedAction: "none" };
return {
...contract,
recommendedAction: `Restore ${contract.missingSecretKeys.join(", ")} in the canonical Compose env file without printing values, then rerun deploy apply --env ${environment} --service ${serviceId} --dry-run before any live apply.`,
};
}
export function runtimeSecretContractFromPresence(
source: RuntimeSecretSource,
requirements: RuntimeSecretRequirement[],
presence: RuntimeSecretPresence[],
): RuntimeSecretContract {
const missingSecretKeys = presence.filter((item) => !item.present).map((item) => item.sourceEnvName);
const requiredSecretsPresent = requirements.length === 0 || (source.exists && missingSecretKeys.length === 0);
return {
check: "runtime-secret-presence",
secretSource: source,
requiredSecretsPresent,
missingSecretKeys,
recommendedAction: runtimeSecretRecommendedAction(missingSecretKeys),
valuesPrinted: false,
requirements: presence.map((item) => ({
sourceEnvName: item.sourceEnvName,
containerEnvName: item.containerEnvName,
present: item.present,
valuePrinted: false,
})),
dryRunDisposition: requirements.length === 0
? "not-required"
: requiredSecretsPresent
? "ready-for-live-apply"
: "secret-source-blocked",
};
}
export function runtimeSecretContractFromEnvText(
envText: string,
requirements: RuntimeSecretRequirement[],
sourceOverrides: Partial<Omit<RuntimeSecretSource, "kind" | "valuesPrinted">> = {},
): RuntimeSecretContract {
const source: RuntimeSecretSource = {
kind: "compose-env-file",
path: sourceOverrides.path ?? "<env-text>",
exists: sourceOverrides.exists ?? true,
configSource: sourceOverrides.configSource ?? "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile",
workDir: sourceOverrides.workDir ?? "",
composeEnvFile: sourceOverrides.composeEnvFile ?? "",
composeService: sourceOverrides.composeService ?? null,
containerName: sourceOverrides.containerName ?? null,
valuesPrinted: false,
};
return runtimeSecretContractFromPresence(source, requirements, runtimeSecretPresenceFromEnvText(envText, requirements));
}
export function runtimeSecretContractForComposeTarget(
config: UniDeskConfig,
target: ArtifactConsumerTarget,
requirements: RuntimeSecretRequirement[] | undefined,
): RuntimeSecretContract | undefined {
if (requirements === undefined) return undefined;
const source = runtimeSecretSourceForTarget(config, target);
const envText = source.exists ? readFileSync(source.path, "utf8") : "";
return runtimeSecretContractFromPresence(source, requirements, runtimeSecretPresenceFromEnvText(envText, requirements));
}
export function composeArtifactSecretPreflight(envFile: string, requirements: RuntimeSecretRequirement[] | undefined): { ok: boolean; envFile: string; requirements: RuntimeSecretPresence[]; missing: RuntimeSecretPresence[]; valuesLogged: false } {
const requirementList = requirements ?? [];
const envText = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
const presence = runtimeSecretPresenceFromEnvText(envText, requirementList);
const missing = presence.filter((item) => !item.present);
return {
ok: requirementList.length === 0 || (existsSync(envFile) && missing.length === 0),
envFile,
requirements: presence,
missing,
valuesLogged: false,
};
}
export function authHealthGateScript(gate: AuthHealthGate | undefined, healthJsonShellArg: string): string[] {
if (gate === undefined) return [];
return [
`auth_health_gate_fields=${shellQuote(gate.requiredFields.join(","))}`,
`python3 - ${healthJsonShellArg} "$auth_health_gate_fields" <<'PY'`,
"import json, sys",
"path, fields_text = sys.argv[1:]",
"fields = [item for item in fields_text.split(',') if item]",
"body = json.load(open(path, encoding='utf-8'))",
"auth = body.get('auth') if isinstance(body, dict) else None",
"auth = auth if isinstance(auth, dict) else {}",
"failed = [field for field in fields if auth.get(field) is not True]",
"if failed:",
" print('baidu_netdisk_auth_health_gate_failed=' + ','.join(failed))",
" raise SystemExit(1)",
"print('baidu_netdisk_auth_health_gate=ok')",
"PY",
];
}
export function upsertEnvFileValues(path: string, values: Record<string, string>): void {
const existing = readFileSync(path, "utf8");
const seen = new Set<string>();
const lines = existing.split(/\n/u).filter((line, index, array) => index < array.length - 1 || line.length > 0).map((line) => {
const match = /^([A-Za-z0-9_]+)=/u.exec(line);
if (match === null || values[match[1]] === undefined) return line;
seen.add(match[1]);
return `${match[1]}=${values[match[1]]}`;
});
for (const [key, value] of Object.entries(values)) {
if (!seen.has(key)) lines.push(`${key}=${value}`);
}
writeFileSync(path, `${lines.join("\n")}\n`, "utf8");
}
export async function pullArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): Promise<CommandResult> {
const localArchive = `/tmp/unidesk-artifact-${safeName(options.serviceId ?? "service")}-${safeName(options.commit ?? "commit")}-${Date.now().toString(36)}.tar.gz`;
const remoteArchive = `/tmp/unidesk-artifact-${safeName(options.serviceId ?? "service")}-${safeName(options.commit ?? "commit")}-${Date.now().toString(36)}.tar.gz`;
const remoteScript = [
"set -euo pipefail",
`image=${shellQuote(sourceImage)}`,
`archive=${shellQuote(remoteArchive)}`,
"rm -f \"$archive\"",
"export DOCKER_CONFIG=$(mktemp -d /tmp/unidesk-artifact-docker-config.XXXXXX)",
"trap 'rm -rf \"$DOCKER_CONFIG\"' EXIT",
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
"docker pull -q \"$image\" >/dev/null",
"docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }} remote_dockerfile={{ index .Config.Labels \"unidesk.ai/dockerfile\" }}' >&2",
"docker save \"$image\" | gzip -1 > \"$archive\"",
"remote_archive_bytes=$(wc -c < \"$archive\" | tr -d '[:space:]')",
"remote_archive_sha256=$(sha256sum \"$archive\" | awk '{print $1}')",
"printf 'remote_archive=%s\\nremote_archive_bytes=%s\\nremote_archive_sha256=%s\\n' \"$archive\" \"$remote_archive_bytes\" \"$remote_archive_sha256\"",
].join("\n");
const archive = await runRemoteScriptBackground(options, remoteScript, Math.max(options.timeoutMs, 900_000), "docker-save");
if (archive.exitCode !== 0 || archive.timedOut) return archive;
const download = downloadRemoteFile(options, remoteArchive, localArchive, Math.max(options.timeoutMs, 900_000));
if (download.exitCode !== 0 || download.timedOut) {
runRemoteScript(options, `rm -f ${shellQuote(remoteArchive)}`, 30_000);
try {
rmSync(localArchive, { force: true });
} catch {
// Best-effort cleanup only.
}
return combineCommandResults(["artifact-registry", "pull-artifact-from-d601"], [archive, download]);
}
const load = runCommand(["bash", "-lc", `set -euo pipefail; gzip -dc ${shellQuote(localArchive)} | docker load`], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 900_000) });
runRemoteScript(options, `rm -f ${shellQuote(remoteArchive)}`, 30_000);
try {
rmSync(localArchive, { force: true });
} catch {
// Best-effort cleanup only.
}
return combineCommandResults(["artifact-registry", "pull-artifact-from-d601"], [archive, download, load]);
}
+248
View File
@@ -0,0 +1,248 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. status module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:1081-1318 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactRegistryFailureClassification, ArtifactRegistryOptions, RenderedBundle } from "./types";
import { renderBundle, shellQuote } from "./bundle";
export function plan(options: ArtifactRegistryOptions): Record<string, unknown> {
const bundle = renderBundle(options);
return {
ok: true,
providerId: options.providerId,
mode: "d601-host-managed",
firstStage: true,
runtime: {
implementation: "CNCF Distribution Docker registry",
image: options.image,
endpoint: `http://${options.host}:${options.port}`,
publicExposure: "none",
orchestration: "systemd + Docker Compose on D601 host/WSL OS",
k3sManaged: false,
},
dependencies: [
{ name: "Docker Engine", scope: "D601 host", requiredFor: "running the registry container" },
{ name: "Docker Compose plugin", scope: "D601 host", requiredFor: "systemd ExecStart/ExecStop" },
{ name: "systemd", scope: "D601 host", requiredFor: "host-managed service lifecycle" },
{ name: "registry:2.8.3", scope: "D601 Docker cache / upstream pull", requiredFor: "CNCF Distribution runtime image" },
{ name: "provider-gateway Host SSH", scope: "UniDesk control bridge", requiredFor: "readonly status and health checks" },
{ name: "local filesystem storage", scope: bundle.paths.storage, requiredFor: "artifact persistence outside k3s" },
],
boundaries: [
"listen only on D601 host loopback 127.0.0.1:5000",
"do not expose a public port, NodePort, hostPort, or third-party registry",
"do not run inside k3s; keep the registry outside the native k3s failure domain",
"CI builds and publishes backend-core, frontend, and reviewed user-service artifacts on D601",
"CD only pulls, retags, recreates/imports artifacts, and verifies live commit",
"master server CD must not compile Rust or run docker compose build for artifact consumers",
"code-queue is dev-only for artifact consumer validation and has no prod artifact deploy target",
],
renderedPaths: bundle.paths,
artifactConsumerFlow: [
"D601 CI builds unidesk/<service-id>:<commit>",
"D601 CI pushes 127.0.0.1:5000/unidesk/<service-id>:<commit>",
"Compose consumers pull via a controlled provider-gateway SSH image stream",
"k3s consumers pull on D601 and import into native k3s containerd",
"CD retags/recreates or updates Deployment images and verifies live commit metadata",
],
frontendArtifactFlow: [
"D601 CI builds and pushes 127.0.0.1:5000/unidesk/frontend:<commit>",
"dev CD imports the image into D601 native k3s and verifies frontend-dev /health deploy.commit",
"prod CD pulls via provider-gateway image stream, recreates Compose frontend with no build, and verifies /health deploy.commit",
],
};
}
export function statusScript(options: ArtifactRegistryOptions, bundle: RenderedBundle): string {
const hashes = Object.fromEntries(bundle.files.map((item) => [item.path, item.sha256]));
return `set -u
kv() { printf '%s=%s\\n' "$1" "$2"; }
bool_file() { [ -f "$1" ] && printf true || printf false; }
bool_dir() { [ -d "$1" ] && printf true || printf false; }
hash_file() { sha256sum "$1" 2>/dev/null | awk '{print $1}' || true; }
unit=${shellQuote(bundle.paths.unit)}
compose=${shellQuote(bundle.paths.compose)}
config=${shellQuote(bundle.paths.config)}
storage=${shellQuote(bundle.paths.storage)}
container=${shellQuote(options.containerName)}
expected_image=${shellQuote(options.image)}
port=${options.port}
kv readonly true
kv unit_exists "$(bool_file "$unit")"
kv compose_exists "$(bool_file "$compose")"
kv config_exists "$(bool_file "$config")"
kv storage_exists "$(bool_dir "$storage")"
if command -v systemctl >/dev/null 2>&1; then
kv systemctl_available true
kv unit_active "$(systemctl is-active "${options.unitName}" 2>/dev/null || true)"
kv unit_enabled "$(systemctl is-enabled "${options.unitName}" 2>/dev/null || true)"
else
kv systemctl_available false
kv unit_active unknown
kv unit_enabled unknown
fi
if command -v docker >/dev/null 2>&1; then
kv docker_available true
container_running="$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)"
container_status="$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || true)"
container_image="$(docker inspect -f '{{.Config.Image}}' "$container" 2>/dev/null || true)"
container_restart_policy="$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$container" 2>/dev/null || true)"
kv container_running "$([ -n "$container_running" ] && printf '%s' "$container_running" || printf false)"
kv container_status "$container_status"
kv container_image "$container_image"
kv container_restart_policy "$container_restart_policy"
else
kv docker_available false
kv container_running false
kv container_status unknown
kv container_image ""
kv container_restart_policy unknown
fi
if command -v ss >/dev/null 2>&1; then
listeners="$(ss -ltnH 2>/dev/null | awk -v p=":$port" '$4 ~ p "$" {print $4}' || true)"
else
listeners=""
fi
listener_count="$(printf '%s\\n' "$listeners" | sed '/^$/d' | wc -l | tr -d ' ')"
bad_listener_count="$(printf '%s\\n' "$listeners" | sed '/^$/d' | grep -Ev "^(127[.]0[.]0[.]1|localhost):${options.port}$|^\\[::1\\]:${options.port}$|^::1:${options.port}$" | wc -l | tr -d ' ')"
loopback_only=false
if [ "\${listener_count:-0}" -gt 0 ] && [ "\${bad_listener_count:-0}" -eq 0 ]; then loopback_only=true; fi
kv listener_count "\${listener_count:-0}"
kv bad_listener_count "\${bad_listener_count:-0}"
kv loopback_only "$loopback_only"
if command -v curl >/dev/null 2>&1; then
kv curl_available true
kv v2_http_code "$(timeout 3 curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:${options.port}/v2/ 2>/dev/null || true)"
else
kv curl_available false
kv v2_http_code ""
fi
config_hash="$(hash_file "$config")"
compose_hash="$(hash_file "$compose")"
unit_hash="$(hash_file "$unit")"
kv config_hash_matches "$([ -n "$config_hash" ] && [ "$config_hash" = ${shellQuote(hashes[bundle.paths.config] ?? "")} ] && printf true || printf false)"
kv compose_hash_matches "$([ -n "$compose_hash" ] && [ "$compose_hash" = ${shellQuote(hashes[bundle.paths.compose] ?? "")} ] && printf true || printf false)"
kv unit_hash_matches "$([ -n "$unit_hash" ] && [ "$unit_hash" = ${shellQuote(hashes[bundle.paths.unit] ?? "")} ] && printf true || printf false)"
kv image_matches "$([ "\${container_image:-}" = "$expected_image" ] && printf true || printf false)"
kv config_hash "$config_hash"
kv compose_hash "$compose_hash"
kv unit_hash "$unit_hash"
`;
}
export function parseKeyValueOutput(stdout: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of stdout.split(/\r?\n/u)) {
const match = /^([A-Za-z0-9_]+)=(.*)$/u.exec(line);
if (match !== null) values[match[1]] = match[2];
}
return values;
}
export function asBool(value: string | undefined): boolean {
return value === "true";
}
export function registryRecommendedAction(classification: ArtifactRegistryFailureClassification | null): string {
if (classification === null) return "none";
if (classification === "control-plane-missing") return "restore the artifact registry readonly control plane, or rerun through --main-server-ip <host> once frontend/backend-core dispatch is reachable";
if (classification === "local-docker-required") return "run the read-only check through --main-server-ip <host> or from a main-server CLI with backend-core available";
if (classification === "provider-ssh-command-missing") return "restore D601 provider-gateway host.ssh dispatch/capability before retrying artifact registry health";
if (classification === "ssh-helper-command-shape-incompatible") return "upgrade the CLI/control-plane host.ssh helper shape so it can run bash -lc readonly probes";
if (classification === "remote-command-timeout") return "rerun artifact-registry health and inspect D601 host.ssh latency if the timeout repeats";
if (classification === "registry-not-installed") return "install the D601 artifact registry through the controlled artifact-registry install path, then rerun health";
return "restore the D601 artifact registry service/container/API until registry health passes";
}
export function readonlyRemoteCommandShape(action: "status" | "health", options: ArtifactRegistryOptions): string {
if (isLocalProvider(options.providerId)) return `local bash -lc <artifact-registry-${action}-readonly-script> timeoutMs=${options.timeoutMs}`;
return `host.ssh provider=${options.providerId} mode=exec argv=bash -lc <artifact-registry-${action}-readonly-script> timeoutMs=${options.timeoutMs}`;
}
export function isLocalProvider(providerId: string): boolean {
return providerId === "local" || providerId === "D601-local";
}
export function classifyProviderSshCommandFailure(command: CommandResult): ArtifactRegistryFailureClassification {
const output = `${command.stderr}\n${command.stdout}`.toLowerCase();
if (command.timedOut || output.includes("timed out") || output.includes("timeout")) return "remote-command-timeout";
if (output.includes("no such container: unidesk-backend-core") || output.includes("no such container: unidesk-database") || output.includes("backend-core bridge unavailable")) {
return "local-docker-required";
}
if (output.includes("provider does not declare host.ssh") || output.includes("provider is offline") || output.includes("host.ssh capability")) {
return "provider-ssh-command-missing";
}
if (output.includes("remote frontend transport does not support") || output.includes("ssh helper") || output.includes("unsupported command") || output.includes("bad substitution") || output.includes("command is too long")) {
return "ssh-helper-command-shape-incompatible";
}
if (command.exitCode === 126 || command.exitCode === 127 || output.includes("command not found") || output.includes("exec format error")) {
return "ssh-helper-command-shape-incompatible";
}
return "provider-ssh-command-missing";
}
export function providerSshCommandFailureScopes(classification: ArtifactRegistryFailureClassification): string[] {
const scopes = ["provider-ssh-command"];
if (classification === "control-plane-missing") return ["control-plane-missing", ...scopes];
if (classification === "remote-command-timeout") scopes.push("remote-command-timeout");
if (classification === "local-docker-required") scopes.push("local-docker-control-plane");
if (classification === "ssh-helper-command-shape-incompatible") scopes.push("ssh-helper-command-shape");
return scopes;
}
export function registryHealthFailureClassification(checks: Record<string, boolean>, failedScopes: string[]): ArtifactRegistryFailureClassification | null {
if (failedScopes.length === 0) return null;
const installed = checks.unitExists === true
|| checks.composeExists === true
|| checks.configExists === true
|| checks.storageExists === true
|| checks.containerRunning === true;
return installed ? "registry-unhealthy" : "registry-not-installed";
}
export function registryHealthDecision(checks: Record<string, boolean>, commandOk: boolean): Record<string, unknown> {
const scopeChecks: Array<[string, boolean]> = [
["systemd", checks.systemctlAvailable === true && checks.unitExists === true && checks.unitActive === true],
["docker", checks.dockerAvailable === true],
["registry-container", checks.containerRunning === true],
["loopback-listener", checks.loopbackOnly === true],
["registry-api", checks.v2Ok === true],
["storage", checks.storageExists === true],
["rendered-config", checks.configHashMatches === true && checks.composeHashMatches === true && checks.unitHashMatches === true],
["registry-image", checks.imageMatches === true],
];
const healthyScopes = scopeChecks.filter(([, ok]) => ok).map(([scope]) => scope);
const failedScopes = scopeChecks.filter(([, ok]) => !ok).map(([scope]) => scope);
const runtimeApiHealthy = checks.containerRunning === true && checks.loopbackOnly === true && checks.v2Ok === true;
const failureClassification = commandOk ? registryHealthFailureClassification(checks, failedScopes) : "provider-ssh-command-missing";
return {
decision: commandOk && failedScopes.length === 0 ? "healthy" : commandOk || runtimeApiHealthy ? "service-degraded" : "retryable-transient",
retryable: true,
failureClassification,
healthyScopes,
failedScopes,
recommendedAction: registryRecommendedAction(failureClassification),
runtimeApiHealthy,
};
}
+257
View File
@@ -0,0 +1,257 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. types module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:1-248 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import { composeFile, sha256 } from "./bundle";
export type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
export type ArtifactDeployEnvironment = "prod" | "dev";
export interface ArtifactRegistryOptions {
environment: ArtifactDeployEnvironment | null;
providerId: string;
host: string;
port: number;
image: string;
baseDir: string;
storageDir: string;
unitName: string;
composeProject: string;
serviceName: string;
containerName: string;
timeoutMs: number;
dryRun: boolean;
runNow: boolean;
commit: string | null;
targetImage: string | null;
serviceId: string | null;
sourceRepo: string;
sourceRepoExplicit: boolean;
deployRef: string | null;
deployJsonService: DeployJsonServiceContract | null;
}
export interface ArtifactRegistryCommandRuntime {
env?: NodeJS.ProcessEnv;
runRemoteScriptForTest?: (options: ArtifactRegistryOptions, script: string, timeoutMs: number) => CommandResult;
runCliForTest?: (command: string[], timeoutMs: number) => CommandResult;
}
export interface RenderedFile {
path: string;
mode: string;
sha256: string;
content: string;
}
export interface RenderedBundle {
files: RenderedFile[];
paths: {
unit: string;
compose: string;
config: string;
storage: string;
baseDir: string;
};
}
export interface ArtifactRegistryReadonlyProbe {
action: "status" | "health";
providerId: string;
script: string;
timeoutMs: number;
healthMode: boolean;
options: ArtifactRegistryOptions;
remoteCommandShape: string;
}
export type ArtifactRegistryFailureClassification =
| "control-plane-missing"
| "local-docker-required"
| "provider-ssh-command-missing"
| "ssh-helper-command-shape-incompatible"
| "remote-command-timeout"
| "registry-not-installed"
| "registry-unhealthy";
export const defaultOptions: ArtifactRegistryOptions = {
environment: null,
providerId: "D601",
host: "127.0.0.1",
port: 5000,
image: "registry:2.8.3",
baseDir: "/home/ubuntu/.unidesk/artifact-registry",
storageDir: "/home/ubuntu/.unidesk/registry-storage",
unitName: "unidesk-artifact-registry.service",
composeProject: "unidesk-artifact-registry",
serviceName: "registry",
containerName: "unidesk-artifact-registry",
timeoutMs: 30_000,
dryRun: false,
runNow: false,
commit: null,
targetImage: null,
serviceId: null,
sourceRepo: "https://github.com/pikasTech/unidesk",
sourceRepoExplicit: false,
deployRef: null,
deployJsonService: null,
};
export const supportedArtifactConsumerServices = [
"auth-broker",
"backend-core",
"baidu-netdisk",
"claudeqq",
"code-queue",
"code-queue-mgr",
"decision-center",
"findjob",
"frontend",
"k3sctl-adapter",
"mdtodo",
"met-nonlinear",
"oa-event-flow",
"pipeline",
"project-manager",
"todo-note",
] as const;
export type SupportedArtifactConsumerService = typeof supportedArtifactConsumerServices[number];
export const legacyDeployBackendCoreDisabled = true;
export interface ArtifactConsumerSpec {
serviceId: SupportedArtifactConsumerService;
environment?: ArtifactDeployEnvironment;
kind: "compose" | "d601-compose" | "d601-k3s";
registryRepository: string;
sourceRepo?: string;
dockerfile: string;
targets: Partial<Record<ArtifactDeployEnvironment, ArtifactConsumerTarget>>;
prodLiveApply: "enabled" | "supervisor-only" | "unsupported";
prodLiveBlockReason?: string;
devLiveApply?: "enabled" | "supervisor-only";
devLiveBlockReason?: string;
runtimeVerification?: "strict" | "blocked";
runtimeVerificationBlockReason?: string;
}
export interface ArtifactConsumerTarget {
targetImage: string;
targetCommitImage: (commit: string) => string;
deployRef: string;
compose?: {
serviceName: string;
containerName: string;
deployEnvPrefix: string;
healthProbeCommand: string;
requireHealthCommit: boolean;
workDir?: string;
composeFile?: string;
projectHint?: string;
requiredRuntimeSecrets?: RuntimeSecretRequirement[];
authHealthGate?: AuthHealthGate;
syntheticHealthDeployProof?: SyntheticHealthDeployProof;
};
k3s?: {
namespace: string;
manifestRepoPath: string;
deploymentName: string;
serviceName: string;
servicePort: number;
containerName: string;
healthPath: string;
extraDeployments?: Array<string | { name: string; containerName?: string }>;
applySelector?: string;
podLabelSelector?: string;
};
}
export interface RuntimeSecretRequirement {
sourceEnvName: string;
containerEnvName: string;
}
export interface AuthHealthGate {
requiredFields: string[];
recoveryHint: string;
}
export interface SyntheticHealthDeployProof {
kind: "compose-container-runtime-metadata";
description: string;
}
export interface ComposeArtifactRuntime {
workDir: string;
composeFile: string;
composeEnvFile: string;
envFile: string;
project: string;
command: string[];
}
export interface RuntimeSecretPresence {
sourceEnvName: string;
containerEnvName: string;
present: boolean;
length: number;
}
export interface RuntimeSecretSource {
kind: "compose-env-file";
path: string;
exists: boolean;
configSource: "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile";
workDir: string;
composeEnvFile: string;
composeService: string | null;
containerName: string | null;
valuesPrinted: false;
}
export interface RuntimeSecretRequirementStatus {
sourceEnvName: string;
containerEnvName: string;
present: boolean;
valuePrinted: false;
}
export interface RuntimeSecretContract {
check: "runtime-secret-presence";
secretSource: RuntimeSecretSource;
requiredSecretsPresent: boolean;
missingSecretKeys: string[];
recommendedAction: string;
valuesPrinted: false;
requirements: RuntimeSecretRequirementStatus[];
dryRunDisposition: "not-required" | "ready-for-live-apply" | "secret-source-blocked";
}
export const todoNoteSyntheticHealthDeployProof: SyntheticHealthDeployProof = {
kind: "compose-container-runtime-metadata",
description: "todo-note /api/health does not natively expose deploy metadata, so the artifact consumer synthesizes health.deploy from the recreated container's UNIDESK_DEPLOY_* env and verifies it against image/container labels.",
};