3602 lines
170 KiB
TypeScript
3602 lines
170 KiB
TypeScript
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";
|
|
|
|
export type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
|
|
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;
|
|
}
|
|
|
|
interface RenderedFile {
|
|
path: string;
|
|
mode: string;
|
|
sha256: string;
|
|
content: string;
|
|
}
|
|
|
|
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";
|
|
|
|
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,
|
|
};
|
|
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;
|
|
type SupportedArtifactConsumerService = typeof supportedArtifactConsumerServices[number];
|
|
const legacyDeployBackendCoreDisabled = true;
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface AuthHealthGate {
|
|
requiredFields: string[];
|
|
recoveryHint: string;
|
|
}
|
|
|
|
interface SyntheticHealthDeployProof {
|
|
kind: "compose-container-runtime-metadata";
|
|
description: string;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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.",
|
|
};
|
|
|
|
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" },
|
|
];
|
|
|
|
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.",
|
|
};
|
|
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
function isHelpArg(value: string | undefined): boolean {
|
|
return value === undefined || value === "help" || value === "--help" || value === "-h";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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, "");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function sha256(text: string): string {
|
|
return createHash("sha256").update(text).digest("hex");
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function d601K3sGuardScript(): string {
|
|
return d601K3sGuardShellLines().join("\n");
|
|
}
|
|
|
|
function base64(value: string): string {
|
|
return Buffer.from(value, "utf8").toString("base64");
|
|
}
|
|
|
|
function safeName(value: string): string {
|
|
return value.replace(/[^A-Za-z0-9_.-]/gu, "-");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function file(path: string, content: string, mode = "0644"): RenderedFile {
|
|
return { path, mode, sha256: sha256(content), content };
|
|
}
|
|
|
|
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
|
|
`;
|
|
}
|
|
|
|
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}
|
|
`;
|
|
}
|
|
|
|
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
|
|
`;
|
|
}
|
|
|
|
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)),
|
|
],
|
|
};
|
|
}
|
|
|
|
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",
|
|
],
|
|
};
|
|
}
|
|
|
|
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"
|
|
`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function asBool(value: string | undefined): boolean {
|
|
return value === "true";
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
function isLocalProvider(providerId: string): boolean {
|
|
return providerId === "local" || providerId === "D601-local";
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
function commandTail(result: CommandResult): Record<string, unknown> {
|
|
return artifactRegistryCommandTail(result);
|
|
}
|
|
|
|
function artifactConsumerSpec(serviceId: string, environment: ArtifactDeployEnvironment | null): ArtifactConsumerSpec | null {
|
|
const key = environment === null || environment === "prod" ? serviceId : `${environment}:${serviceId}`;
|
|
const explicit = artifactConsumerSpecs[key];
|
|
if (explicit !== undefined) return explicit;
|
|
const shared = artifactConsumerSpecs[serviceId];
|
|
return shared ?? null;
|
|
}
|
|
|
|
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,
|
|
})));
|
|
}
|
|
|
|
function artifactConsumerTarget(spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment | null): ArtifactConsumerTarget | null {
|
|
return spec.targets[environment ?? "prod"] ?? null;
|
|
}
|
|
|
|
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",
|
|
};
|
|
}
|
|
|
|
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",
|
|
};
|
|
}
|
|
|
|
function artifactConsumerLivePolicy(spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment): "enabled" | "supervisor-only" | "unsupported" {
|
|
return environment === "prod" ? spec.prodLiveApply : spec.devLiveApply ?? "enabled";
|
|
}
|
|
|
|
function artifactConsumerLiveBlockReason(spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment): string | null {
|
|
if (spec.runtimeVerificationBlockReason !== undefined) return spec.runtimeVerificationBlockReason;
|
|
return environment === "prod" ? spec.prodLiveBlockReason ?? null : spec.devLiveBlockReason ?? null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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",
|
|
],
|
|
};
|
|
}
|
|
|
|
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",
|
|
],
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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",
|
|
};
|
|
}
|
|
|
|
|
|
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}`;
|
|
}
|
|
|
|
function sourceRepoFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string {
|
|
return options.sourceRepoExplicit ? options.sourceRepo : spec.sourceRepo ?? options.sourceRepo;
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function targetImageFor(options: ArtifactRegistryOptions, target: ArtifactConsumerTarget): string {
|
|
return (options.dryRun ? options.deployJsonService?.consumer?.target.stableImage : undefined) ?? target.targetImage;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function registryRepositoryFor(options: ArtifactRegistryOptions, spec: ArtifactConsumerSpec): string {
|
|
return (options.dryRun ? options.deployJsonService?.artifact?.repository : undefined) ?? spec.registryRepository;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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"),
|
|
};
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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`,
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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.",
|
|
};
|
|
}
|
|
|
|
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("; ");
|
|
}
|
|
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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.";
|
|
}
|
|
|
|
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.`,
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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",
|
|
];
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function registryArtifactMissingMessage(spec: ArtifactConsumerSpec): string {
|
|
return `${spec.serviceId} image artifact is missing from D601 registry; run CI artifact publication first`;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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",
|
|
];
|
|
}
|
|
|
|
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}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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.",
|
|
};
|
|
}
|
|
|
|
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.",
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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.",
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
function serviceIdFromDeployBackendCoreArgs(action: ArtifactRegistryAction, options: ArtifactRegistryOptions): string | null {
|
|
return action === "deploy-backend-core" ? "backend-core" : options.serviceId;
|
|
}
|
|
|
|
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");
|
|
}
|