import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { runCommand } from "./command"; import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "./config"; import { ensureGithubSshIdentityForProvider } from "./deploy-ssh-identity"; import { startJob } from "./jobs"; import { coreInternalFetch } from "./microservices"; type DeployAction = "check" | "plan" | "apply"; type DeployEnvironment = "dev" | "prod"; interface DeployManifestService { id: string; repo: string; commitId: string; } interface DeployManifest { schemaVersion: 1 | 2; environment: DeployEnvironment | null; services: DeployManifestService[]; } interface DeployOptions { file: string; environment: DeployEnvironment | null; serviceId: string | null; runNow: boolean; dryRun: boolean; force: boolean; timeoutMs: number; } interface StepResult { step: string; ok: boolean; detail: string; startedAt: string; finishedAt: string; raw?: unknown; } interface DispatchResult { ok: boolean; taskId: string | null; status: string | null; stdout: string; stderr: string; exitCode: number | null; raw: unknown; } interface BackgroundPoll { done: boolean; exitCode: number | null; logTail: string; raw: unknown; } interface RemoteScriptUpload { ok: boolean; path: string; error: string; raw: unknown[]; } interface ServiceRuntimeState { serviceId: string; ok: boolean; supported: boolean; reason: string | null; desiredRepo: string; desiredCommit: string; providerId: string; deploymentMode: string; currentCommit: string | null; healthCommit: string | null; imageCommit: string | null; orchestratorCommit: string | null; healthOk: boolean; upToDate: boolean; raw?: unknown; } interface DeployEnvironmentTarget { environment: DeployEnvironment; remote: string; branch: string; gitRef: string; namespace: string; runtimeScope: string; database: { serviceId: string; host: string; port: number; name: string; }; provider: { providerId: string; nodeId: string; credentialScope: string; }; } interface DeployEnvironmentManifestSource { source: "git-ref"; path: "deploy.json"; ref: string; remote: string; branch: string; commit: string; blob: string; fetchedAt: string; } const defaultDeployFile = "deploy.json"; const defaultTimeoutMs = 900_000; const resolveFetchTimeout = "120s"; const shortDispatchWaitMs = 60_000; const shortRemoteTimeoutMs = 20_000; const providerDispatchCompletionLagMs = 45_000; const pollIntervalMs = 5_000; const remoteDeployRoot = "/home/ubuntu/.unidesk/deploy"; const k8sNamespace = "unidesk"; const k8sKubeconfig = "/etc/rancher/k3s/k3s.yaml"; const k3sDeployDir = "/home/ubuntu/cq-deploy"; const providerGatewayWsEgressProxyUrl = "http://127.0.0.1:18789"; const nativeK3sInstallVersion = "v1.34.1+k3s1"; const nativeK3sImage = "rancher/k3s:v1.34.1-k3s1"; const nativeK3sCtrAddress = "/run/k3s/containerd/containerd.sock"; const unideskRepoUrl = "https://github.com/pikasTech/unidesk"; const d601MaintenanceDeployAllowedServiceIds = new Set(["backend-core", "frontend", "k3sctl-adapter"]); const devApplySupportedServiceIds = new Set(["backend-core", "frontend"]); const deployEnvironmentTargets: Record = { dev: { environment: "dev", remote: "origin", branch: "master", gitRef: "origin/master", namespace: "unidesk-dev", runtimeScope: "d601-k3s-dev", database: { serviceId: "postgres-dev", host: "postgres-dev.unidesk-dev.svc.cluster.local", port: 5432, name: "unidesk_dev", }, provider: { providerId: "D601-dev", nodeId: "D601", credentialScope: "dev-only", }, }, prod: { environment: "prod", remote: "origin", branch: "master", gitRef: "origin/master", namespace: "unidesk", runtimeScope: "prod-main-server-compose-and-d601-k3s", database: { serviceId: "postgres-prod", host: "database.unidesk-main-server", port: 5432, name: "unidesk", }, provider: { providerId: "D601", nodeId: "D601", credentialScope: "prod", }, }, }; function isHelpArg(value: string | undefined): boolean { return value === "help" || value === "--help" || value === "-h"; } export function deployHelp(action: string | undefined = undefined): Record { const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply" : `deploy ${action}`; return { ok: true, command, usage: { check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]", plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]", apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev] [--service id] [--dry-run] [--force] [--timeout-ms N] [--run-now]", }, actions: { check: "Validate desired repo+commit state against live service health and commit markers.", plan: "Show desired/live drift, or with --env show the environment-ref dry-run plan without touching runtime resources.", apply: "Start an async target-side reconcile job unless --run-now is explicitly present.", }, options: [ { name: "--file ", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local manifest apply has one D601 direct-service exception: k3sctl-adapter." }, { name: "--env ", description: "Read the named environment from origin/master:deploy.json. Dev apply is enabled only for backend-core and frontend in D601 unidesk-dev; prod apply is disabled." }, { name: "--service ", description: "Limit reconcile to one service from the manifest." }, { name: "--dry-run", description: "Prepare and validate without mutating the target service." }, { name: "--force", description: "Redeploy even when the live commit appears up to date." }, { name: "--timeout-ms ", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." }, { name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." }, ], }; } function nowIso(): string { return new Date().toISOString(); } function elapsedMs(startedAt: number): number { return Math.max(0, Date.now() - startedAt); } function shellQuote(value: string): string { return `'${value.replace(/'/gu, `'\\''`)}'`; } function compactTail(text: string, maxChars = 1600): string { return text.length > maxChars ? text.slice(text.length - maxChars) : text; } function progressLine(step: string, message: string, detail?: unknown): void { const payload = detail === undefined ? { at: nowIso(), step, message } : { at: nowIso(), step, message, detail }; process.stderr.write(`${JSON.stringify(payload)}\n`); } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function asString(value: unknown): string { return typeof value === "string" ? value : ""; } function parseJsonObjectFromText(text: string): unknown | null { const start = text.indexOf("{"); if (start < 0) return null; let depth = 0; let inString = false; let escaped = false; for (let index = start; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === "\"") { inString = false; } continue; } if (char === "\"") { inString = true; continue; } if (char === "{") { depth += 1; continue; } if (char === "}") { depth -= 1; if (depth === 0) return JSON.parse(text.slice(start, index + 1)) as unknown; } } return null; } function parseFullCommit(value: string): string { const match = value.match(/\b[0-9a-f]{40}\b/iu); return match?.[0]?.toLowerCase() ?? ""; } function safeId(value: string): string { const sanitized = value.replace(/[^A-Za-z0-9_.-]/gu, "-"); if (sanitized.length === 0) throw new Error(`invalid service id: ${value}`); return sanitized; } function repoResolveCacheDir(repo: string): string { return rootPath(".state", "deploy", "resolve", createHash("sha256").update(repo).digest("hex").slice(0, 16)); } function repoSlug(repo: string): string | null { const trimmed = repo.trim().replace(/\.git$/u, ""); const https = trimmed.match(/^https:\/\/([^/]+)\/(.+)$/u); if (https !== null) return `${https[1]?.toLowerCase()}/${https[2]}`; const ssh = trimmed.match(/^git@([^:]+):(.+)$/u); if (ssh !== null) return `${ssh[1]?.toLowerCase()}/${ssh[2]}`; return null; } function isFullGitSha(value: string): boolean { return /^[0-9a-f]{40}$/iu.test(value); } function isDeployEnvironment(value: string): value is DeployEnvironment { return value === "dev" || value === "prod"; } function isUnideskRepo(repo: string): boolean { const desiredSlug = repoSlug(repo); const unideskSlug = repoSlug(unideskRepoUrl); return desiredSlug !== null && desiredSlug === unideskSlug; } function sshUrlForSlug(slug: string | null): string | null { if (slug === null) return null; const [host = "", ...pathParts] = slug.split("/"); const path = pathParts.join("/"); if (host.length === 0 || path.length === 0) return null; if (host !== "github.com" && host !== "gitee.com") return null; return `git@${host}:${path}.git`; } function candidateRepoUrls(repo: string): string[] { const desiredSlug = repoSlug(repo); const urls = [repo]; const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot); const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : ""; if (localOriginUrl.length > 0 && repoSlug(localOriginUrl) === desiredSlug) urls.push(localOriginUrl); const sshUrl = sshUrlForSlug(desiredSlug); if (sshUrl !== null) urls.push(sshUrl); return [...new Set(urls)]; } function providerSourceRepoUrl(repo: string): string { const slug = repoSlug(repo); if (slug === null || (!slug.startsWith("github.com/") && !slug.startsWith("gitee.com/"))) return repo; return sshUrlForSlug(slug) ?? repo; } function localResolvedCommitForDesired(desired: DeployManifestService): string | null { const desiredSlug = repoSlug(desired.repo); if (desiredSlug === null || !isFullGitSha(desired.commitId)) return null; const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot); const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : ""; if (localOriginUrl.length === 0 || repoSlug(localOriginUrl) !== desiredSlug) return null; const resolved = runCommand(["git", "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot); const fullCommit = parseFullCommit(resolved.stdout); return resolved.exitCode === 0 && fullCommit === desired.commitId.toLowerCase() ? fullCommit : null; } function commandFailure(result: ReturnType, maxChars = 1200): string { const detail = [result.stderr, result.stdout].filter(Boolean).join("\n"); return compactTail(detail, maxChars) || `exit ${result.exitCode}`; } function runGitOrThrow(args: string[], cwd: string, message: string): ReturnType { const result = runCommand(["git", ...args], cwd); if (result.exitCode !== 0) throw new Error(`${message}: ${commandFailure(result)}`); return result; } function runResolveGit(args: string[], cwd: string): ReturnType { return runCommand(["timeout", resolveFetchTimeout, "git", ...args], cwd); } function resolveDesiredCommit(desired: DeployManifestService): DeployManifestService { const localCommit = localResolvedCommitForDesired(desired); if (localCommit !== null) return { ...desired, commitId: localCommit }; if (isFullGitSha(desired.commitId) && !isUnideskRepo(desired.repo)) return { ...desired, commitId: desired.commitId.toLowerCase() }; const cacheDir = repoResolveCacheDir(desired.repo); mkdirSync(cacheDir, { recursive: true }); if (!existsSync(join(cacheDir, ".git", "config"))) { const init = runCommand(["git", "init", cacheDir], repoRoot); if (init.exitCode !== 0 && !existsSync(join(cacheDir, ".git", "config"))) { throw new Error(`failed to initialize deploy commit resolver for ${desired.repo}: ${commandFailure(init)}`); } } const fetchErrors: string[] = []; for (const repoUrl of candidateRepoUrls(desired.repo)) { runCommand(["git", "-C", cacheDir, "remote", "remove", "origin"], repoRoot); const addRemote = runCommand(["git", "-C", cacheDir, "remote", "add", "origin", repoUrl], repoRoot); if (addRemote.exitCode !== 0) { fetchErrors.push(`${repoUrl}: ${commandFailure(addRemote)}`); continue; } const fetches = desired.commitId.length === 40 ? [ runResolveGit(["-C", cacheDir, "fetch", "--no-tags", "--depth=1", "origin", desired.commitId], repoRoot), runResolveGit([ "-C", cacheDir, "fetch", "--no-tags", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*", ], repoRoot), ] : [ runResolveGit([ "-C", cacheDir, "fetch", "--no-tags", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*", ], repoRoot), ]; const fetch = fetches.find((result) => result.exitCode === 0) ?? fetches[fetches.length - 1]; if (fetch.exitCode === 0) { fetchErrors.length = 0; break; } fetchErrors.push(`${repoUrl}: ${commandFailure(fetch)}`); } if (fetchErrors.length > 0) { throw new Error(`deploy manifest service ${desired.id} cannot fetch ${desired.repo} before deploy: ${compactTail(fetchErrors.join("\n"), 1600)}`); } const resolved = runCommand(["git", "-C", cacheDir, "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot); const fullCommit = parseFullCommit(resolved.stdout); if (resolved.exitCode !== 0 || fullCommit.length !== 40) { throw new Error(`deploy manifest service ${desired.id} commitId ${desired.commitId} cannot be resolved in ${desired.repo}; use a pushed unique 7-40 char SHA. ${commandFailure(resolved)}`); } return { ...desired, commitId: fullCommit }; } function resolveManifestCommits(manifest: DeployManifest, serviceId: string | null): DeployManifest { return { schemaVersion: manifest.schemaVersion, environment: manifest.environment, services: manifest.services.map((service) => (serviceId === null || service.id === serviceId ? resolveDesiredCommit(service) : service)), }; } function optionValue(args: string[], names: string[]): string | undefined { for (const name of names) { const index = args.indexOf(name); if (index === -1) continue; const raw = args[index + 1]; if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`); return raw; } return undefined; } function positiveIntegerOption(args: string[], names: string[], defaultValue: number): number { const raw = optionValue(args, names); if (raw === undefined) return defaultValue; const parsed = Number(raw); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${names[0]} must be a positive integer`); return parsed; } function environmentOption(args: string[]): DeployEnvironment | null { const raw = optionValue(args, ["--env", "--environment"]); if (raw === undefined) return null; if (!isDeployEnvironment(raw)) throw new Error("--env must be dev or prod"); return raw; } function parseOptions(args: string[]): DeployOptions { const environment = environmentOption(args); if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file"); return { file: optionValue(args, ["--file"]) ?? defaultDeployFile, environment, serviceId: optionValue(args, ["--service", "--service-id"]) ?? null, runNow: args.includes("--run-now"), dryRun: args.includes("--dry-run"), force: args.includes("--force"), timeoutMs: positiveIntegerOption(args, ["--timeout-ms"], defaultTimeoutMs), }; } function parseDeployManifestService(item: unknown, index: number, seen: Set, path: string): DeployManifestService { const service = asRecord(item); if (service === null) throw new Error(`deploy manifest ${path}[${index}] must be an object`); const id = asString(service.id); const repo = asString(service.repo); const commitId = asString(service.commitId).toLowerCase(); if (id.length === 0) throw new Error(`deploy manifest ${path}[${index}].id must be a non-empty string`); if (repo.length === 0) throw new Error(`deploy manifest ${path}[${index}].repo must be a non-empty string`); if (!/^[0-9a-f]{7,40}$/iu.test(commitId)) throw new Error(`deploy manifest ${path}[${index}].commitId must be a 7-40 char git SHA`); if (seen.has(id)) throw new Error(`duplicate deploy manifest service id: ${id}`); seen.add(id); return { id, repo, commitId }; } function parseDeployManifestServices(value: unknown, path: string): DeployManifestService[] { if (!Array.isArray(value)) throw new Error(`deploy manifest ${path} must be an array`); const seen = new Set(); return value.map((item, index) => parseDeployManifestService(item, index, seen, path)); } function parseDeployManifest(parsed: unknown, source: string, expectedEnvironment: DeployEnvironment | null): DeployManifest { const record = asRecord(parsed); if (record === null) throw new Error(`deploy manifest ${source} must be an object`); if (record.schemaVersion !== 1 && record.schemaVersion !== 2) throw new Error("deploy manifest schemaVersion must be 1 or 2"); if (record.schemaVersion === 2) { const environments = asRecord(record.environments); if (environments === null) throw new Error("deploy manifest schemaVersion=2 requires environments"); if (expectedEnvironment === null) { const prod = asRecord(environments.prod); if (prod === null) throw new Error("deploy manifest schemaVersion=2 requires environments.prod for local compatibility mode"); return { schemaVersion: 2, environment: "prod", services: parseDeployManifestServices(prod.services, "environments.prod.services") }; } const envRecord = asRecord(environments[expectedEnvironment]); if (envRecord === null) throw new Error(`deploy manifest ${source} does not contain environments.${expectedEnvironment}`); return { schemaVersion: 2, environment: expectedEnvironment, services: parseDeployManifestServices(envRecord.services, `environments.${expectedEnvironment}.services`) }; } const rawEnvironment = record.environment; let environment: DeployEnvironment | null = null; if (rawEnvironment !== undefined) { if (typeof rawEnvironment !== "string" || !isDeployEnvironment(rawEnvironment)) { throw new Error("deploy manifest environment must be dev or prod when present"); } environment = rawEnvironment; } if (expectedEnvironment !== null) { if (environment === null) throw new Error(`deploy manifest ${source} must declare environment=${expectedEnvironment} when --env ${expectedEnvironment} is used`); if (environment !== expectedEnvironment) { throw new Error(`deploy manifest ${source} declares environment=${environment}, refusing --env ${expectedEnvironment}`); } } return { schemaVersion: 1, environment, services: parseDeployManifestServices(record.services, "services") }; } function readEnvironmentDeployManifest(environment: DeployEnvironment): { manifest: DeployManifest; source: DeployEnvironmentManifestSource } { const target = deployEnvironmentTargets[environment]; const remoteRef = `refs/remotes/${target.remote}/${target.branch}`; const fetch = runCommand(["git", "fetch", "--no-tags", target.remote, `+refs/heads/${target.branch}:${remoteRef}`], repoRoot, { timeoutMs: 60_000 }); if (fetch.exitCode !== 0) { throw new Error(`failed to fetch ${target.gitRef} for deploy --env ${environment}: ${commandFailure(fetch)}`); } const commit = parseFullCommit(runGitOrThrow(["rev-parse", "--verify", `${target.gitRef}^{commit}`], repoRoot, `failed to resolve ${target.gitRef}`).stdout); if (commit.length !== 40) throw new Error(`failed to resolve a full commit for ${target.gitRef}`); const blob = runGitOrThrow(["rev-parse", `${target.gitRef}:deploy.json`], repoRoot, `failed to resolve ${target.gitRef}:deploy.json`).stdout.trim().toLowerCase(); if (!/^[0-9a-f]{40}$/iu.test(blob)) throw new Error(`failed to resolve a deploy.json blob for ${target.gitRef}`); const raw = runGitOrThrow(["show", `${target.gitRef}:deploy.json`], repoRoot, `failed to read ${target.gitRef}:deploy.json`).stdout; const manifest = parseDeployManifest(JSON.parse(raw) as unknown, `${target.gitRef}:deploy.json#environments.${environment}`, environment); return { manifest, source: { source: "git-ref", path: "deploy.json", ref: target.gitRef, remote: target.remote, branch: target.branch, commit, blob, fetchedAt: nowIso(), }, }; } async function readDeployManifest(file: string): Promise { const path = resolve(repoRoot, file); if (!existsSync(path)) throw new Error(`deploy manifest not found: ${path}`); if (/\.(?:mjs|js|cjs)$/iu.test(path)) { const moduleValue = await import(`${pathToFileURL(path).href}?unideskDeployManifest=${Date.now()}`); const moduleRecord = asRecord(moduleValue); const parsed = moduleRecord?.default ?? moduleRecord?.manifest; if (parsed === undefined) throw new Error(`deploy JS manifest must export default or named manifest: ${path}`); return parseDeployManifest(parsed, path, null); } return parseDeployManifest(JSON.parse(readFileSync(path, "utf8")) as unknown, path, null); } function frontendCoreDeployService(config: UniDeskConfig): UniDeskMicroserviceConfig { return { id: "frontend", name: "UniDesk Frontend", providerId: "main-server", description: "UniDesk core frontend entrypoint deployed by the main-server direct Compose executor.", repository: { url: unideskRepoUrl, commitId: "local", dockerfile: "src/components/frontend/Dockerfile", composeFile: config.docker.composeFile, composeService: "frontend", containerName: "unidesk-frontend", }, backend: { nodeBaseUrl: `http://127.0.0.1:${config.network.frontend.port}`, nodeBindHost: "127.0.0.1", nodePort: config.network.frontend.port, proxyMode: "core-direct", frontendOnly: false, public: true, allowedMethods: ["GET", "HEAD"], allowedPathPrefixes: ["/"], healthPath: "/health", timeoutMs: 8000, }, deployment: { mode: "unidesk-direct" }, development: { providerId: "main-server", sshPassthrough: false, worktreePath: repoRoot, }, frontend: { route: "/", integrated: true, }, }; } function devK3sDeployService(id: string): UniDeskMicroserviceConfig | undefined { const specs: Record = { "backend-core": { name: "UniDesk Dev Backend Core", description: "Isolated dev backend-core deployed into D601 native k3s namespace unidesk-dev.", dockerfile: "src/components/backend-core/Dockerfile", composeFile: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml", composeService: "backend-core-dev", containerName: "k3s:backend-core-dev", nodeBaseUrl: "k3s://backend-core-dev", nodePort: 8080, healthPath: "/health", route: "/dev/backend-core", allowedMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"], allowedPathPrefixes: ["/", "/api/", "/logs"], }, frontend: { name: "UniDesk Dev Frontend", description: "Isolated dev frontend deployed into D601 native k3s namespace unidesk-dev.", dockerfile: "src/components/frontend/Dockerfile", composeFile: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml", composeService: "frontend-dev", containerName: "k3s:frontend-dev", nodeBaseUrl: "k3s://frontend-dev", nodePort: 8080, healthPath: "/health", route: "/dev/frontend", allowedMethods: ["GET", "HEAD"], allowedPathPrefixes: ["/"], }, "code-queue": { name: "UniDesk Dev Code Queue", description: "Isolated dev Code Queue execution plane deployed into D601 native k3s namespace unidesk-dev.", dockerfile: "src/components/microservices/code-queue/Dockerfile", composeFile: "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", composeService: "code-queue-scheduler-dev", containerName: "k3s:code-queue-scheduler-dev", nodeBaseUrl: "k3s://code-queue-dev", nodePort: 4222, healthPath: "/health", route: "/dev/code-queue", allowedMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"], allowedPathPrefixes: ["/", "/api/", "/logs"], }, }; const spec = specs[id]; if (spec === undefined) return undefined; return { id, name: spec.name, providerId: "D601", description: spec.description, repository: { url: unideskRepoUrl, commitId: "deploy-env", dockerfile: spec.dockerfile, composeFile: spec.composeFile, composeService: spec.composeService, containerName: spec.containerName, }, backend: { nodeBaseUrl: spec.nodeBaseUrl, nodeBindHost: `k3s://unidesk-dev/${spec.composeService}`, nodePort: spec.nodePort, proxyMode: "dev-k3s-direct", frontendOnly: true, public: false, allowedMethods: spec.allowedMethods, allowedPathPrefixes: spec.allowedPathPrefixes, healthPath: spec.healthPath, timeoutMs: 30_000, }, deployment: { mode: "k3sctl-managed", adapterServiceId: "k3sctl-adapter", k3sServiceId: spec.composeService, namespace: "unidesk-dev", expectedNodeIds: ["D601"], activeNodeId: "D601", }, development: { providerId: "D601", sshPassthrough: true, worktreePath: id === "code-queue" ? "/home/ubuntu/unidesk-dev-code-queue-deploy/code-queue" : `/home/ubuntu/unidesk-dev-core-deploy/${id}`, }, frontend: { route: spec.route, integrated: false, }, }; } function coreDeployService(config: UniDeskConfig, id: string, environment: DeployEnvironment | null): UniDeskMicroserviceConfig | undefined { if (environment === "dev") return devK3sDeployService(id); if (id === "frontend") return frontendCoreDeployService(config); return undefined; } function isCoreDeployService(service: UniDeskMicroserviceConfig): boolean { return service.id === "frontend" && service.providerId === "main-server" && service.backend.proxyMode === "core-direct"; } function isDevK3sDeployService(service: UniDeskMicroserviceConfig): boolean { return service.deployment.mode === "k3sctl-managed" && devApplySupportedServiceIds.has(service.id); } function isD601MaintenanceDeployBlocked(service: UniDeskMicroserviceConfig): boolean { if (targetIsMain(service)) return false; if (service.providerId !== "D601") return false; return !d601MaintenanceDeployAllowedServiceIds.has(service.id); } function isDirectComposeDeployMode(service: UniDeskMicroserviceConfig): boolean { return service.deployment.mode === "unidesk-direct" || service.deployment.mode === "internal-sidecar"; } function selectServices(config: UniDeskConfig, manifest: DeployManifest, serviceId: string | null): Array<{ desired: DeployManifestService; config: UniDeskMicroserviceConfig; }> { const configById = new Map(config.microservices.map((service) => [service.id, service])); const selected = serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === serviceId); if (serviceId !== null && selected.length === 0) throw new Error(`deploy manifest does not contain service: ${serviceId}`); return selected.map((desired) => { if (manifest.environment === "dev") { const service = devK3sDeployService(desired.id); if (service === undefined) { throw new Error(`deploy --env dev service ${desired.id} is not enabled for direct rollout in the current CI-only phase`); } return { desired, config: service }; } const service = configById.get(desired.id) ?? coreDeployService(config, desired.id, manifest.environment); if (service === undefined) throw new Error(`deploy manifest service ${desired.id} is not present in config.json microservices or supported core deploy services`); return { desired, config: service }; }); } function unsupportedReason(service: UniDeskMicroserviceConfig): string | null { if (service.repository.dockerfile.startsWith("docker.io/")) return "image-only service has no Dockerfile source artifact"; if (service.repository.composeFile.startsWith("docker run")) return "docker-run image-only service has no compose/k8s build path"; if (service.repository.commitId === "local") return null; return null; } function databaseFingerprint(target: DeployEnvironmentTarget): string { const material = [ target.environment, target.namespace, target.database.serviceId, target.database.host, target.database.port.toString(), target.database.name, target.provider.providerId, ].join("|"); return `sha256:${createHash("sha256").update(material).digest("hex").slice(0, 16)}`; } function environmentTargetSummary(target: DeployEnvironmentTarget): Record { return { namespace: target.namespace, runtimeScope: target.runtimeScope, database: { ...target.database, fingerprint: databaseFingerprint(target), }, provider: target.provider, }; } function targetIsMain(service: UniDeskMicroserviceConfig): boolean { return service.providerId === "main-server"; } function targetDeployRoot(service: UniDeskMicroserviceConfig): string { return targetIsMain(service) ? rootPath(".state", "deploy") : remoteDeployRoot; } function targetRepoDir(service: UniDeskMicroserviceConfig): string { return `${targetDeployRoot(service)}/repos/${safeId(service.id)}`; } function targetExportDir(service: UniDeskMicroserviceConfig, runId: string): string { return `${targetDeployRoot(service)}/exports/${safeId(service.id)}-${runId}`; } function targetWorkDir(service: UniDeskMicroserviceConfig): string { if (isDevK3sDeployService(service)) return service.development.worktreePath; if (service.deployment.mode === "k3sctl-managed") return k3sDeployDir; if (targetIsMain(service) && isUnideskRepo(service.repository.url)) { return rootPath(".state", "deploy", "work", safeId(service.id)); } return service.development.worktreePath; } function sourceWorkDir(service: UniDeskMicroserviceConfig): string { if (service.deployment.mode === "k3sctl-managed" && service.repository.url !== unideskRepoUrl) { return `${remoteDeployRoot}/work/${safeId(service.id)}`; } return targetWorkDir(service); } function sourceFallbackWorktree(service: UniDeskMicroserviceConfig): string { if (targetIsMain(service) || isUnideskRepo(service.repository.url)) return ""; return service.development.worktreePath; } function sourceRootSubdir(service: UniDeskMicroserviceConfig): string { const claudeqqPrefix = "claudeqq/"; if (service.id === "claudeqq" && service.repository.url !== unideskRepoUrl && service.repository.dockerfile.startsWith(claudeqqPrefix)) { return "claudeqq"; } return ""; } function sourceBuildContext(service: UniDeskMicroserviceConfig): string { const subdir = sourceRootSubdir(service); return subdir.length > 0 ? `${sourceWorkDir(service)}/${subdir}` : sourceWorkDir(service); } function buildImageTag(service: UniDeskMicroserviceConfig): string { if (isDevK3sDeployService(service)) return `unidesk-${service.id}:dev`; if (service.deployment.mode === "k3sctl-managed") return `unidesk-${service.id}:d601`; if (targetIsMain(service)) { if (["project-manager", "baidu-netdisk", "oa-event-flow"].includes(service.repository.composeService)) return service.repository.composeService; return `unidesk-${service.repository.composeService}`; } return `unidesk-${service.id}:${service.providerId.toLowerCase()}`; } export function codeQueueContainerdImagePreflight(imageListText: string, expectedImage: string): { ok: boolean; expectedImage: string; matchedLine: string | null; error: string | null } { const matchedLine = imageListText .split(/\r?\n/u) .map((line) => line.trim()) .find((line) => line.length > 0 && line.includes(expectedImage)) ?? null; return matchedLine === null ? { ok: false, expectedImage, matchedLine: null, error: `native k3s containerd is missing required image tag: ${expectedImage}`, } : { ok: true, expectedImage, matchedLine, error: null }; } export function codeQueueManifestImagePreflight(manifestText: string, expectedImage: string): { ok: boolean; expectedImage: string; objects: Array<{ kind: string; name: string; images: string[]; ok: boolean }>; errors: string[] } { const requiredObjectNames = new Set(["code-queue", "code-queue-read", "code-queue-write", "d601-provider-egress-proxy", "d601-tcp-egress-gateway"]); const objects: Array<{ kind: string; name: string; images: string[]; ok: boolean }> = []; for (const documentText of manifestText.split(/^---\s*$/mu)) { const kind = documentText.match(/^kind:\s*(\S+)\s*$/mu)?.[1] ?? ""; if (kind !== "Deployment") continue; const deploymentName = documentText.match(/^metadata:\s*$[\s\S]*?^\s{2}name:\s*([A-Za-z0-9_.-]+)\s*$/mu)?.[1] ?? ""; if (!requiredObjectNames.has(deploymentName)) continue; const images = Array.from(documentText.matchAll(/^\s*image:\s*(?:"([^"]+)"|([^\s#]+))/gmu)).map((match) => match[1] ?? match[2] ?? ""); objects.push({ kind, name: deploymentName, images, ok: images.length > 0 && images.every((image) => image === expectedImage) }); } const presentNames = new Set(objects.map((object) => object.name)); const errors = [ ...Array.from(requiredObjectNames).filter((name) => !presentNames.has(name)).map((name) => `required deployment missing from manifest: ${name}`), ...objects.filter((object) => !object.ok).map((object) => `deployment ${object.name} must use only ${expectedImage}; found ${object.images.join(",") || ""}`), ]; return { ok: errors.length === 0, expectedImage, objects, errors }; } function directComposeFile(service: UniDeskMicroserviceConfig): string { return targetIsMain(service) ? rootPath("docker-compose.yml") : `${targetWorkDir(service)}/${service.repository.composeFile}`; } function directComposeEnvFile(service: UniDeskMicroserviceConfig): string { return targetIsMain(service) ? writeComposeEnvFallbackPath() : ""; } function directBuildContextOverride(service: UniDeskMicroserviceConfig): string { if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return targetWorkDir(service); return ""; } function directDockerfileOverride(service: UniDeskMicroserviceConfig): string { if (targetIsMain(service) && isUnideskRepo(service.repository.url)) return service.repository.dockerfile; return ""; } function k8sManifestPath(service: UniDeskMicroserviceConfig): string { const composeFile = service.repository.composeFile; if (composeFile.endsWith(".k8s.yaml")) return composeFile; if (!composeFile.endsWith(".k3s.json")) throw new Error(`${service.id} k3s service composeFile must point to *.k3s.json`); return composeFile.replace(/\.k3s\.json$/u, ".k8s.yaml"); } function sourceDockerfilePath(service: UniDeskMicroserviceConfig): string { const claudeqqPrefix = "claudeqq/"; if (service.id === "claudeqq" && service.repository.url !== unideskRepoUrl && service.repository.dockerfile.startsWith(claudeqqPrefix)) { return service.repository.dockerfile.slice(claudeqqPrefix.length); } return service.repository.dockerfile; } function sourceProxyPrelude(service: UniDeskMicroserviceConfig): string { if (targetIsMain(service)) return ""; const strictHostKeyChecking = isUnideskRepo(service.repository.url) ? "yes" : "accept-new"; return [ "export DOCKER_CONFIG=/tmp/unidesk-docker-config-clean", "mkdir -p \"$DOCKER_CONFIG\"", "[ -f \"$DOCKER_CONFIG/config.json\" ] || printf '{}' > \"$DOCKER_CONFIG/config.json\"", `build_proxy=${shellQuote(providerGatewayWsEgressProxyUrl)}`, "export HTTP_PROXY=\"$build_proxy\" HTTPS_PROXY=\"$build_proxy\" ALL_PROXY=\"$build_proxy\"", "export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal\"", "git_ssh_proxy=/tmp/unidesk-git-ssh-http-connect.py", "cat > \"$git_ssh_proxy\" <<'UNIDESK_GIT_SSH_PROXY'", String.raw`#!/usr/bin/env python3 import os import select import socket import sys from urllib.parse import urlparse if len(sys.argv) != 3: raise SystemExit("usage: unidesk-git-ssh-http-connect.py host port") target_host = sys.argv[1] target_port = int(sys.argv[2]) proxy = urlparse(os.environ.get("UNIDESK_GIT_SSH_HTTP_PROXY", "http://127.0.0.1:18789")) proxy_host = proxy.hostname or "127.0.0.1" proxy_port = proxy.port or 80 sock = socket.create_connection((proxy_host, proxy_port), timeout=20) sock.sendall(f"CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n".encode("ascii")) header = b"" while b"\r\n\r\n" not in header: chunk = sock.recv(4096) if not chunk: raise SystemExit("proxy closed before CONNECT response") header += chunk head, rest = header.split(b"\r\n\r\n", 1) if not (head.startswith(b"HTTP/1.1 200") or head.startswith(b"HTTP/1.0 200")): sys.stderr.write(head.decode("latin1", "replace") + "\n") raise SystemExit(1) if rest: os.write(1, rest) stdin_open = True sock.setblocking(False) while True: readers = [sock] if stdin_open: readers.append(sys.stdin.buffer) ready, _, _ = select.select(readers, [], []) if sock in ready: try: data = sock.recv(65536) except BlockingIOError: data = b"" if not data: break os.write(1, data) if stdin_open and sys.stdin.buffer in ready: data = os.read(0, 65536) if data: sock.sendall(data) else: stdin_open = False try: sock.shutdown(socket.SHUT_WR) except OSError: pass `, "UNIDESK_GIT_SSH_PROXY", "chmod 700 \"$git_ssh_proxy\"", "export UNIDESK_GIT_SSH_HTTP_PROXY=\"$build_proxy\"", `export GIT_SSH_COMMAND="ssh -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=${strictHostKeyChecking} -o UserKnownHostsFile=$HOME/.ssh/known_hosts -i $HOME/.ssh/id_ed25519 -o 'ProxyCommand=$git_ssh_proxy %h %p'"`, "curl -fsSI --max-time 20 -x \"$build_proxy\" https://github.com >/dev/null", "echo target_source_proxy=provider-gateway-ws-egress:$build_proxy", "echo target_build_proxy=provider-gateway-ws-egress:$build_proxy", "echo target_build_proxy_probe=ok", ].join("\n"); } function buildBaseImageFallbacks(service: UniDeskMicroserviceConfig): string[] { if (isDevK3sDeployService(service) && service.id === "code-queue") { return [ "unidesk-code-queue:d601-build-base", "unidesk-code-queue:d601", ]; } return []; } function buildCachePrelude(dockerfileVariable: string, baseImageFallbacks: string[] = []): string[] { const fallbackLines = baseImageFallbacks.flatMap((image) => [ `if [ "$base_image_found" = "0" ] && docker image inspect ${shellQuote(image)} >/dev/null 2>&1; then`, ` build_base_image=${shellQuote(image)}`, " base_image_found=1", "fi", ]); return [ "cache_args=(--cache-to type=inline --build-arg BUILDKIT_INLINE_CACHE=1)", "if docker image inspect \"$image\" >/dev/null 2>&1; then cache_args+=(--cache-from \"$image\"); echo target_build_cache_from_image=$image; else echo target_build_cache_from_image=missing:$image; fi", "echo target_build_cache_to=inline", "base_args=()", "build_base_image=\"${image}-build-base\"", "base_image_found=0", "if docker image inspect \"$build_base_image\" >/dev/null 2>&1; then base_image_found=1; fi", ...fallbackLines, `if grep -Eq '^ARG[[:space:]]+CODE_QUEUE_BASE_IMAGE([=[:space:]]|$)' "${dockerfileVariable}" 2>/dev/null; then if [ "$base_image_found" = "1" ]; then base_args=(--build-arg "CODE_QUEUE_BASE_IMAGE=$build_base_image"); echo target_build_base_image=$build_base_image; else echo target_build_base_image=default; fi; else echo target_build_base_image=unsupported; fi`, ]; } function dockerBuildTimeoutMs(service: UniDeskMicroserviceConfig, options: DeployOptions): number { const maxBuildMs = service.id === "code-queue" ? 1_800_000 : 540_000; return Math.min(options.timeoutMs, maxBuildMs); } function devK3sPrepullImages(service: UniDeskMicroserviceConfig): string[] { if (!isDevK3sDeployService(service)) return []; if (service.id === "backend-core") return ["rust:1-bookworm", "postgres:16-bookworm"]; return ["oven/bun:1-alpine"]; } function prepareSourceScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, exportDir: string): string { if (targetIsMain(service) && isUnideskRepo(desired.repo)) { return [ "set -euo pipefail", `repo=${shellQuote(repoRoot)}`, `commit=${shellQuote(desired.commitId)}`, `export_dir=${shellQuote(exportDir)}`, "mkdir -p \"$(dirname \"$export_dir\")\"", "git -C \"$repo\" fetch --no-tags origin \"$commit\" || git -C \"$repo\" fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'", "resolved=$(git -C \"$repo\" rev-parse --verify \"$commit^{commit}\")", "rm -rf \"$export_dir\"", "mkdir -p \"$export_dir\"", "git -C \"$repo\" archive --format=tar \"$resolved\" | tar -xf - -C \"$export_dir\"", "printf 'resolved_commit=%s\\nexport_dir=%s\\nsource_repo=%s\\n' \"$resolved\" \"$export_dir\" \"$repo\"", ].join("\n"); } const fallbackWorktree = sourceFallbackWorktree(service); return [ "set -euo pipefail", sourceProxyPrelude(service), `repo=${shellQuote(targetRepoDir(service))}`, `repo_url=${shellQuote(providerSourceRepoUrl(desired.repo))}`, `commit=${shellQuote(desired.commitId)}`, `export_dir=${shellQuote(exportDir)}`, `fallback_worktree=${shellQuote(fallbackWorktree)}`, "mkdir -p \"$(dirname \"$repo\")\" \"$(dirname \"$export_dir\")\"", "source_repo=\"\"", "source_mode=\"remote-fetch\"", "source_log=$(mktemp /tmp/unidesk-deploy-source.XXXXXX.log)", "if { [ -d \"$repo/.git\" ] || { rm -rf \"$repo\" && git clone --no-checkout \"$repo_url\" \"$repo\"; }; } >\"$source_log\" 2>&1; then", " cd \"$repo\"", " if { git remote set-url origin \"$repo_url\" && { git fetch --no-tags origin \"$commit\" || git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'; } && git rev-parse --verify \"$commit^{commit}\" > /tmp/unidesk-deploy-resolved-commit; } >>\"$source_log\" 2>&1; then", " resolved=$(cat /tmp/unidesk-deploy-resolved-commit)", " source_repo=\"$repo\"", " fi", "fi", "if [ -z \"$source_repo\" ]; then", " echo target_source_remote_prepare=failed", " tail -80 \"$source_log\" || true", " if [ -n \"$fallback_worktree\" ] && git -C \"$fallback_worktree\" rev-parse --is-inside-work-tree >/dev/null 2>&1 && git -C \"$fallback_worktree\" rev-parse --verify \"$commit^{commit}\" > /tmp/unidesk-deploy-resolved-commit 2>/dev/null; then", " resolved=$(cat /tmp/unidesk-deploy-resolved-commit)", " source_repo=$(git -C \"$fallback_worktree\" rev-parse --show-toplevel)", " source_mode=\"provider-worktree\"", " printf 'target_source_fallback_worktree=%s\\n' \"$fallback_worktree\"", " else", " cat \"$source_log\" >&2 || true", " exit 128", " fi", "fi", "rm -rf \"$export_dir\"", "mkdir -p \"$export_dir\"", "git -C \"$source_repo\" archive --format=tar \"$resolved\" | tar -xf - -C \"$export_dir\"", "rm -f \"$source_log\" /tmp/unidesk-deploy-resolved-commit", "printf 'resolved_commit=%s\\nexport_dir=%s\\nsource_repo=%s\\nsource_mode=%s\\n' \"$resolved\" \"$export_dir\" \"$source_repo\" \"$source_mode\"", ].filter((line) => line.length > 0).join("\n"); } function syncSourceScript(service: UniDeskMicroserviceConfig, exportDir: string): string { const workDir = sourceWorkDir(service); const buildContext = sourceBuildContext(service); const dockerfilePath = sourceDockerfilePath(service); const overlayCommands = service.id === "claudeqq" ? claudeqqDeployAssetOverlayCommands() : []; return [ "set -euo pipefail", `export_dir=${shellQuote(exportDir)}`, `work_dir=${shellQuote(workDir)}`, `build_context=${shellQuote(buildContext)}`, `dockerfile_path=${shellQuote(dockerfilePath)}`, "mkdir -p \"$work_dir\"", [ "rsync -a --delete", "--exclude '.git/'", "--exclude '.state/'", "--exclude 'logs/'", "--exclude '**/node_modules/'", "--exclude '**/dist/'", "\"$export_dir/\"", "\"$work_dir/\"", ].join(" "), ...overlayCommands, "test -f \"$build_context/$dockerfile_path\"", "printf 'synced deploy worktree to %s\\nbuild_context=%s\\n' \"$work_dir\" \"$build_context\"", ].join("\n"); } function claudeqqDeployAssetOverlayCommands(): string[] { const assets = [ { relativePath: "$build_context/$dockerfile_path", sourcePath: rootPath("src/components/microservices/claudeqq/Dockerfile"), label: "Dockerfile", }, { relativePath: "$build_context/unidesk-adapter.cjs", sourcePath: rootPath("src/components/microservices/claudeqq/adapter.js"), label: "unidesk-adapter.cjs", }, ]; return assets.flatMap((asset) => { if (!existsSync(asset.sourcePath)) throw new Error(`claudeqq deploy asset missing: ${asset.sourcePath}`); const encoded = Buffer.from(readFileSync(asset.sourcePath, "utf8"), "utf8").toString("base64"); return [ `mkdir -p "$(dirname "${asset.relativePath}")"`, `printf %s ${shellQuote(encoded)} | base64 -d > "${asset.relativePath}"`, `printf 'synced_claudeqq_deploy_asset=%s\\n' ${shellQuote(asset.label)}`, ]; }); } function syncK8sControlManifestsScript(service: UniDeskMicroserviceConfig): string { if (isDevK3sDeployService(service)) { const manifest = k8sManifestPath(service); return [ "set -euo pipefail", `target_root=${shellQuote(targetWorkDir(service))}`, `relative_path=${shellQuote(manifest)}`, "target_file=\"$target_root/$relative_path\"", "test -f \"$target_file\"", "printf 'target_k3s_control_manifest=%s\\n' \"$target_file\"", ].join("\n"); } if (service.deployment.mode !== "k3sctl-managed" || isUnideskRepo(service.repository.url)) return ""; const manifests = service.repository.composeFile.endsWith(".k8s.yaml") ? [service.repository.composeFile] : [service.repository.composeFile, k8sManifestPath(service)]; const commands = [ "set -euo pipefail", `target_root=${shellQuote(targetWorkDir(service))}`, "mkdir -p \"$target_root\"", ]; for (const relativePath of manifests) { const absolutePath = rootPath(relativePath); if (!existsSync(absolutePath)) throw new Error(`${service.id} k3s control manifest missing: ${relativePath}`); const encoded = Buffer.from(readFileSync(absolutePath, "utf8"), "utf8").toString("base64"); commands.push( `relative_path=${shellQuote(relativePath)}`, `target_file="$target_root/$relative_path"`, "mkdir -p \"$(dirname \"$target_file\")\"", `printf %s ${shellQuote(encoded)} | base64 -d > "$target_file"`, "printf 'synced_k3s_control_manifest=%s\\n' \"$target_file\"", ); } return commands.join("\n"); } function buildImageScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string { const image = buildImageTag(service); const buildContext = sourceBuildContext(service); const dockerfilePath = sourceDockerfilePath(service); const dockerfile = `${buildContext}/${dockerfilePath}`; const labelArgs = [ "--label", `unidesk.ai/service-id=${service.id}`, "--label", `unidesk.ai/source-repo=${desired.repo}`, "--label", `unidesk.ai/source-commit=${resolvedCommit}`, "--label", `unidesk.ai/dockerfile=${dockerfilePath}`, ]; const commonArgs = [ "--progress=plain", ...labelArgs, "-t", image, "-f", dockerfile, buildContext, ]; const proxyBuildArgs = targetIsMain(service) ? [] : [ "--network", "host", "--build-arg", `HTTP_PROXY=${providerGatewayWsEgressProxyUrl}`, "--build-arg", `HTTPS_PROXY=${providerGatewayWsEgressProxyUrl}`, "--build-arg", `ALL_PROXY=${providerGatewayWsEgressProxyUrl}`, "--build-arg", "NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal", ]; return [ "set -euo pipefail", sourceProxyPrelude(service), `image=${shellQuote(image)}`, `dockerfile=${shellQuote(dockerfile)}`, "if ! docker buildx version >/dev/null 2>&1; then", " if ! docker buildx inspect default >/dev/null 2>&1; then echo target_build_builder=missing >&2; exit 1; fi", "fi", ...devK3sPrepullImages(service).flatMap((imageName) => [ `if ! docker image inspect ${shellQuote(imageName)} >/dev/null 2>&1; then`, ` docker pull ${shellQuote(imageName)}`, "fi", ]), "builder_args=()", "if docker buildx inspect --builder default >/dev/null 2>&1; then builder_args=(--builder default); echo target_build_builder=default; else echo target_build_builder=implicit; fi", "docker buildx inspect \"${builder_args[@]}\" --bootstrap || true", "echo target_build_builder_cleanup=not-required", ...buildCachePrelude("$dockerfile", buildBaseImageFallbacks(service)), `docker buildx build "\${builder_args[@]}" --load "\${cache_args[@]}" "\${base_args[@]}" ${[...proxyBuildArgs, ...commonArgs].map(shellQuote).join(" ")}`, "docker image inspect \"$image\" --format 'image_id={{.Id}} labels={{json .Config.Labels}}'", ].filter((line) => line.length > 0).join("\n"); } function patchDevK3sManifestScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string { const manifest = `${targetWorkDir(service)}/${k8sManifestPath(service)}`; const image = buildImageTag(service); const serviceId = service.id; const deploymentName = service.repository.composeService; return [ "set -euo pipefail", `manifest=${shellQuote(manifest)}`, `service_id=${shellQuote(serviceId)}`, `deployment_name=${shellQuote(deploymentName)}`, `image=${shellQuote(image)}`, `repo=${shellQuote(desired.repo)}`, `commit=${shellQuote(resolvedCommit)}`, `requested_commit=${shellQuote(desired.commitId)}`, `python3 - "$manifest" "$service_id" "$deployment_name" "$image" "$repo" "$commit" "$requested_commit" <<'PY'`, "import re", "import sys", "path, service_id, deployment_name, image, repo, commit, requested_commit = sys.argv[1:]", "text = open(path, encoding='utf-8').read()", "segments = re.split(r'(?m)^---\\s*$', text)", "kept = []", "for segment in segments:", " if not segment.strip():", " continue", " haystack = '\\n' + segment + '\\n'", " if f'unidesk.ai/deploy-service-id: {service_id}' in segment or f'\\n name: {deployment_name}\\n' in haystack:", " kept.append(segment)", "if not kept:", " raise SystemExit(f'dev service {service_id}/{deployment_name} not found in {path}')", "patched = []", "for segment in kept:", " segment = segment.replace('unidesk.ai/image-source: deploy-env-commit', 'unidesk.ai/image-source: deploy-env-commit')", " segment = re.sub(r'image: unidesk-[^\\n]+:dev-placeholder', f'image: {image}', segment)", " segment = re.sub(r'value: replace-with-deploy-env-commit', f'value: {commit}', segment)", " segment = segment.replace('value: https://github.com/pikasTech/unidesk', f'value: {repo}')", " patched.append(segment.strip() + '\\n')", "out = '---\\n'.join(patched)", "open(path, 'w', encoding='utf-8').write(out)", "print(f'dev_k3s_manifest_patched={path}')", "print(f'dev_k3s_manifest_service={service_id}')", "print(f'dev_k3s_manifest_deployment={deployment_name}')", "print(f'dev_k3s_manifest_image={image}')", "print(f'dev_k3s_manifest_commit={commit}')", "PY", ].join("\n"); } function directComposeResolveScript(service: UniDeskMicroserviceConfig): string { const projectHint = targetIsMain(service) ? "unidesk" : ""; return [ `work_dir=${shellQuote(targetWorkDir(service))}`, `compose_file=${shellQuote(directComposeFile(service))}`, `compose_env_file=${shellQuote(directComposeEnvFile(service))}`, `compose_service=${shellQuote(service.repository.composeService)}`, `container=${shellQuote(service.repository.containerName)}`, `project_hint=${shellQuote(projectHint)}`, `build_context_override=${shellQuote(directBuildContextOverride(service))}`, `build_dockerfile_override=${shellQuote(directDockerfileOverride(service))}`, "compose_env_args=()", "if [ -n \"$compose_env_file\" ]; then compose_env_args=(--env-file \"$compose_env_file\"); fi", "running_project=$(docker inspect -f '{{ index .Config.Labels \"com.docker.compose.project\" }}' \"$container\" 2>/dev/null || true)", "running_service=$(docker inspect -f '{{ index .Config.Labels \"com.docker.compose.service\" }}' \"$container\" 2>/dev/null || true)", "running_image=$(docker inspect -f '{{.Config.Image}}' \"$container\" 2>/dev/null || true)", "if [ \"$running_project\" = '' ]; then running_project=''; fi", "if [ \"$running_service\" = '' ]; then running_service=''; fi", "project=\"$running_project\"", "if [ -z \"$project\" ]; then project=\"$project_hint\"; fi", "if [ -z \"$project\" ]; then project=$(basename \"$work_dir\"); fi", "config_json=$(docker compose \"${compose_env_args[@]}\" -f \"$compose_file\" -p \"$project\" config --format json)", "compose_image=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; d=json.load(sys.stdin); print(((d.get(\"services\") or {}).get(svc) or {}).get(\"image\") or \"\")' \"$compose_service\")", "image=\"$compose_image\"", "if [ -z \"$image\" ]; then image=\"$running_image\"; fi", "if [ -z \"$image\" ]; then image=\"${project}-${compose_service}\"; fi", "build_context=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); print((b if isinstance(b,str) else b.get(\"context\")) or \".\")' \"$compose_service\")", "build_dockerfile=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); print(\"Dockerfile\" if isinstance(b,str) else (b.get(\"dockerfile\") or \"Dockerfile\"))' \"$compose_service\")", "build_target=$(printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); print(\"\" if isinstance(b,str) else (b.get(\"target\") or \"\"))' \"$compose_service\")", "build_args_file=$(mktemp /tmp/unidesk-compose-build-args.XXXXXX)", "printf '%s' \"$config_json\" | python3 -c 'import json,sys; svc=sys.argv[1]; b=(((json.load(sys.stdin).get(\"services\") or {}).get(svc) or {}).get(\"build\") or {}); args={} if isinstance(b,str) else (b.get(\"args\") or {}); items=args.items() if isinstance(args,dict) else [(str(x).split(\"=\",1)[0], str(x).split(\"=\",1)[1] if \"=\" in str(x) else \"\") for x in args]; [print(str(k)+\"=\"+(\"\" if v is None else str(v))) for k,v in items]' \"$compose_service\" > \"$build_args_file\"", "if [ -n \"$build_context_override\" ]; then build_context=\"$build_context_override\"; fi", "if [ -n \"$build_dockerfile_override\" ]; then build_dockerfile=\"$build_dockerfile_override\"; fi", "case \"$build_context\" in /*) ;; *) build_context=$(cd \"$(dirname \"$compose_file\")\" && cd \"$build_context\" && pwd) ;; esac", "case \"$build_dockerfile\" in /*) dockerfile_abs=\"$build_dockerfile\" ;; *) dockerfile_abs=\"$build_context/$build_dockerfile\" ;; esac", "test -f \"$dockerfile_abs\"", "printf 'compose_project=%s\\ncompose_service=%s\\ncompose_image=%s\\nbuild_context=%s\\nbuild_dockerfile=%s\\nbuild_target=%s\\n' \"$project\" \"$compose_service\" \"$image\" \"$build_context\" \"$dockerfile_abs\" \"$build_target\"", ].join("\n"); } function buildDirectImageScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string { const proxyBuildArgs = targetIsMain(service) ? [] : [ "--network", "host", "--build-arg", `HTTP_PROXY=${providerGatewayWsEgressProxyUrl}`, "--build-arg", `HTTPS_PROXY=${providerGatewayWsEgressProxyUrl}`, "--build-arg", `ALL_PROXY=${providerGatewayWsEgressProxyUrl}`, "--build-arg", "NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal", ]; const labelArgs = [ "--label", `unidesk.ai/service-id=${service.id}`, "--label", `unidesk.ai/source-repo=${desired.repo}`, "--label", `unidesk.ai/source-commit=${resolvedCommit}`, "--label", `unidesk.ai/dockerfile=${service.repository.dockerfile}`, ]; return [ "set -euo pipefail", sourceProxyPrelude(service), directComposeResolveScript(service), "builder_args=()", "if docker buildx inspect --builder default >/dev/null 2>&1; then builder_args=(--builder default); echo target_build_builder=default; else echo target_build_builder=implicit; fi", "docker buildx inspect \"${builder_args[@]}\" --bootstrap || true", ...buildCachePrelude("$dockerfile_abs", buildBaseImageFallbacks(service)), "compose_build_args=()", "while IFS= read -r item; do [ -n \"$item\" ] && compose_build_args+=(--build-arg \"$item\"); done < \"$build_args_file\"", "target_args=()", "if [ -n \"$build_target\" ]; then target_args=(--target \"$build_target\"); fi", `docker buildx build "\${builder_args[@]}" --load "\${cache_args[@]}" "\${base_args[@]}" ${[...proxyBuildArgs, ...labelArgs].map(shellQuote).join(" ")} "\${compose_build_args[@]}" "\${target_args[@]}" --progress=plain -t "$image" -f "$dockerfile_abs" "$build_context"`, "docker image inspect \"$image\" --format 'image_id={{.Id}} labels={{json .Config.Labels}}'", ].filter((line) => line.length > 0).join("\n"); } function imageLabelVerifyScript(service: UniDeskMicroserviceConfig, expectedCommit: string): string { return [ "set -euo pipefail", `container=${shellQuote(service.repository.containerName)}`, `expected=${shellQuote(expectedCommit)}`, "cid=$(docker ps -q -f name=\"^/${container}$\" | head -1)", "test -n \"$cid\"", "image_id=$(docker inspect -f '{{.Image}}' \"$cid\")", "actual=$(docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\")", "test \"$actual\" = \"$expected\"", "printf 'container=%s image_id=%s deploy_commit=%s\\n' \"$container\" \"$image_id\" \"$actual\"", ].join("\n"); } function directDeployEnvPrefix(service: UniDeskMicroserviceConfig): string { return `UNIDESK_${service.id.replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase()}_DEPLOY`; } function directComposeDeployEnv(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): Record { if (!isDirectComposeDeployMode(service)) return {}; const prefix = directDeployEnvPrefix(service); return { [`${prefix}_SERVICE_ID`]: service.id, [`${prefix}_REPO`]: desired.repo, [`${prefix}_COMMIT`]: resolvedCommit, [`${prefix}_REQUESTED_COMMIT`]: desired.commitId, }; } function composeEnvStampLines(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string[] { const deployEnv = directComposeDeployEnv(service, desired, resolvedCommit); const entries = Object.entries(deployEnv); if (entries.length === 0) return []; const encoded = Buffer.from(JSON.stringify(deployEnv), "utf8").toString("base64"); return [ `deploy_env_b64=${shellQuote(encoded)}`, "if [ -n \"$compose_env_file\" ]; then", " python3 - \"$compose_env_file\" \"$deploy_env_b64\" <<'PY'", "import base64, json, re, sys", "path = sys.argv[1]", "updates = json.loads(base64.b64decode(sys.argv[2]).decode('utf-8'))", "def env_value(value):", " text = str(value)", " return text if re.match(r'^[A-Za-z0-9_./:@-]+$', text) else json.dumps(text)", "try:", " lines = open(path, encoding='utf-8').read().splitlines()", "except FileNotFoundError:", " lines = []", "seen = set()", "out = []", "for line in lines:", " if '=' not in line or line.startswith('#'):", " out.append(line)", " continue", " key = line.split('=', 1)[0]", " if key in updates:", " out.append(f'{key}={env_value(updates[key])}')", " seen.add(key)", " else:", " out.append(line)", "for key, value in updates.items():", " if key not in seen:", " out.append(f'{key}={env_value(value)}')", "with open(path, 'w', encoding='utf-8') as handle:", " handle.write('\\n'.join(out) + '\\n')", "PY", " echo compose_env_deploy_stamp=updated", "fi", ]; } function composeDeployScript(service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string): string { return [ "set -euo pipefail", directComposeResolveScript(service), ...composeEnvStampLines(service, desired, resolvedCommit), "docker compose \"${compose_env_args[@]}\" -f \"$compose_file\" -p \"$project\" up -d --no-build --no-deps --force-recreate \"$compose_service\"", "ready=0", "for attempt in $(seq 1 90); do", " cid=$(docker ps -q -f name=\"^/${container}$\" | head -1)", " if [ -n \"$cid\" ]; then", " health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \"$cid\" 2>/dev/null || true)", " echo compose_container_probe container=$container attempt=$attempt health=$health", " if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi", " else", " echo compose_container_probe container=$container attempt=$attempt cid=missing", " fi", " sleep 1", "done", "test \"$ready\" = \"1\"", ].join("\n"); } function writeComposeEnvFallbackPath(): string { return rootPath(".state", "docker-compose.env"); } function rootAccessPrelude(): 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 'native_k3s_root_access=missing' >&2", " return 1", "}", "root_shell() {", " script=\"$1\"", " if [ \"$(id -u)\" = \"0\" ]; then bash -lc \"$script\"; return; fi", " if sudo -n true >/dev/null 2>&1; then sudo -n bash -lc \"$script\"; return; fi", " if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- bash -lc \"$script\"; return; fi", " echo 'native_k3s_root_access=missing' >&2", " return 1", "}", ]; } function ensureNativeK3sScript(): string { const installCommand = [ "INSTALL_K3S_SKIP_DOWNLOAD=true", `INSTALL_K3S_VERSION=${nativeK3sInstallVersion}`, "INSTALL_K3S_EXEC=\"server --disable traefik --disable servicelb --disable metrics-server --node-name D601 --node-label unidesk.ai/node-id=D601 --node-label unidesk.ai/provider-id=D601 --tls-san 127.0.0.1 --tls-san host.docker.internal --write-kubeconfig-mode 644\"", "sh /tmp/unidesk-install-k3s.sh", ].join(" "); return [ "set -euo pipefail", ...rootAccessPrelude(), `native_k3s_image=${shellQuote(nativeK3sImage)}`, `native_ctr_address=${shellQuote(nativeK3sCtrAddress)}`, `build_proxy=${shellQuote(providerGatewayWsEgressProxyUrl)}`, "configure_provider_deploy_proxy() {", " export DOCKER_CONFIG=/tmp/unidesk-docker-config-clean", " mkdir -p \"$DOCKER_CONFIG\"", " [ -f \"$DOCKER_CONFIG/config.json\" ] || printf '{}' > \"$DOCKER_CONFIG/config.json\"", " export HTTP_PROXY=\"$build_proxy\" HTTPS_PROXY=\"$build_proxy\" ALL_PROXY=\"$build_proxy\"", " export NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal\"", " if ! curl -fsSI --max-time 20 -x \"$build_proxy\" https://github.com >/dev/null; then", " echo native_k3s_provider_egress_proxy_unavailable=$build_proxy >&2", " exit 1", " fi", " echo native_k3s_provider_egress_proxy=provider-gateway-ws-egress:$build_proxy", "}", "install_native_k3s_binaries() {", " missing=0", " for binary in k3s ctr containerd containerd-shim-runc-v2 crictl runc cni flannel bridge host-local loopback portmap bandwidth firewall; do", " command -v \"$binary\" >/dev/null 2>&1 || missing=1", " done", " if [ \"$missing\" = \"0\" ]; then return; fi", " docker image inspect \"$native_k3s_image\" >/dev/null 2>&1 || docker pull \"$native_k3s_image\"", " tmp_dir=$(mktemp -d)", " tmp_container=$(docker create \"$native_k3s_image\" sh -lc true)", " docker cp \"$tmp_container:/bin/.\" \"$tmp_dir/bin\"", " docker rm \"$tmp_container\" >/dev/null", " for binary in k3s ctr containerd containerd-shim-runc-v2 crictl runc cni flannel bridge host-local loopback portmap bandwidth firewall; do", " if [ -e \"$tmp_dir/bin/$binary\" ]; then", " root_exec install -m 755 \"$tmp_dir/bin/$binary\" \"/usr/local/bin/$binary\"", " echo native_k3s_binary_installed=$binary", " fi", " done", " rm -rf \"$tmp_dir\"", "}", "unmount_invalid_wsl_kubelet_mounts() {", " if awk 'NF != 6 && $0 ~ /\\/Docker\\/host/ {found=1} END {exit found ? 0 : 1}' /proc/mounts; then", " root_exec umount /Docker/host >/dev/null 2>&1 || root_exec umount -l /Docker/host >/dev/null 2>&1 || true", " echo native_k3s_unmounted_invalid_mount=/Docker/host", " fi", " awk 'NF != 6 {print \"native_k3s_invalid_mount_line=\" NR \":\" $0; bad=1} END {exit bad}' /proc/mounts", "}", "install_system_images_from_legacy_k3s() {", " legacy_container=$(docker ps --format '{{.Names}} {{.Image}}' | awk '$2 ~ /^rancher\\/k3s:/ {print $1; exit}')", " if [ -n \"$legacy_container\" ]; then", " if docker exec \"$legacy_container\" ctr -n k8s.io images export /tmp/unidesk-k3s-system-images.tar docker.io/rancher/local-path-provisioner:v0.0.32 docker.io/rancher/mirrored-coredns-coredns:1.12.3 >/dev/null 2>&1; then", " docker cp \"$legacy_container:/tmp/unidesk-k3s-system-images.tar\" /tmp/unidesk-k3s-system-images.tar >/dev/null", " root_exec ctr --address \"$native_ctr_address\" -n k8s.io images import /tmp/unidesk-k3s-system-images.tar >/dev/null", " echo native_k3s_imported_legacy_system_images=$legacy_container", " fi", " fi", "}", "install_pause_image() {", " pause_image=rancher/mirrored-pause:3.6", " pause_entrypoint=$(docker image inspect \"$pause_image\" --format '{{json .Config.Entrypoint}}' 2>/dev/null || true)", " if ! printf '%s' \"$pause_entrypoint\" | grep -q '\"/pause\"'; then", " docker image rm \"$pause_image\" >/dev/null 2>&1 || true", " docker pull --platform linux/amd64 \"$pause_image\"", " pause_entrypoint=$(docker image inspect \"$pause_image\" --format '{{json .Config.Entrypoint}}' 2>/dev/null || true)", " fi", " if ! printf '%s' \"$pause_entrypoint\" | grep -q '\"/pause\"'; then", " echo native_k3s_pause_image_invalid_entrypoint=$pause_entrypoint >&2", " exit 1", " fi", " docker save \"$pause_image\" -o /tmp/unidesk-k3s-pause.tar", " root_exec ctr --address \"$native_ctr_address\" -n k8s.io images rm docker.io/rancher/mirrored-pause:3.6 >/dev/null 2>&1 || true", " root_exec ctr --address \"$native_ctr_address\" -n k8s.io images import /tmp/unidesk-k3s-pause.tar >/dev/null", " root_exec ctr --address \"$native_ctr_address\" -n k8s.io images ls | grep -F 'docker.io/rancher/mirrored-pause:3.6' >/dev/null", " echo native_k3s_imported_pause_image=rancher/mirrored-pause:3.6", "}", "configure_provider_deploy_proxy", "install_native_k3s_binaries", "unmount_invalid_wsl_kubelet_mounts", "if ! systemctl cat k3s.service >/dev/null 2>&1; then", " curl -fsSL --max-time 60 https://get.k3s.io -o /tmp/unidesk-install-k3s.sh", ` root_shell ${shellQuote(installCommand)}`, "fi", "if ! systemctl is-active --quiet k3s; then", " root_exec systemctl enable --now k3s", "fi", "for attempt in $(seq 1 60); do", ` if KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get nodes >/dev/null 2>&1; then break; fi`, " sleep 2", "done", `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get nodes -l unidesk.ai/node-id=D601 --no-headers | grep -q .`, `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl wait --for=condition=Ready node -l unidesk.ai/node-id=D601 --timeout=180s`, "install_system_images_from_legacy_k3s", "install_pause_image", `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n kube-system delete pod -l k8s-app=kube-dns --ignore-not-found >/dev/null 2>&1 || true`, `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n kube-system delete pod -l app=local-path-provisioner --ignore-not-found >/dev/null 2>&1 || true`, "legacy_k3s_containers=$(docker ps --format '{{.Names}} {{.Image}}' | awk '$2 ~ /^rancher\\/k3s:/ {print $1}')", "for container in $legacy_k3s_containers; do", " docker update --restart=no \"$container\" >/dev/null 2>&1 || true", " docker stop \"$container\" >/dev/null", " echo stopped_containerized_k3s=$container", "done", `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get nodes -o wide`, "printf 'native_k3s=ready kubeconfig=%s\\n' /etc/rancher/k3s/k3s.yaml", ].join("\n"); } function importK3sImageScript(service: UniDeskMicroserviceConfig): string { const image = buildImageTag(service); const archive = `/tmp/unidesk-${safeId(service.id)}-k3s-image.tar`; const manifest = `${targetWorkDir(service)}/${k8sManifestPath(service)}`; const codeQueueManifestPreflight = service.id === "code-queue" ? [ "if [ -f \"$manifest\" ]; then", " bad_manifest_images=$(python3 - \"$manifest\" \"$image\" <<'PY'", "import re, sys", "path, expected = sys.argv[1], sys.argv[2]", "required = {'code-queue','code-queue-read','code-queue-write','d601-provider-egress-proxy','d601-tcp-egress-gateway'}", "text = open(path, encoding='utf-8').read()", "bad = []", "seen = set()", "for doc in re.split(r'(?m)^---\\s*$', text):", " if not re.search(r'(?m)^kind:\\s*Deployment\\s*$', doc):", " continue", " match = re.search(r'(?ms)^metadata:\\s*$.*?^ name:\\s*([A-Za-z0-9_.-]+)\\s*$', doc)", " name = match.group(1) if match else ''", " if name not in required:", " continue", " seen.add(name)", " images = re.findall(r'(?m)^\\s*image:\\s*\"?([^\"\\s#]+)\"?', doc)", " if not images or any(image != expected for image in images):", " found = ','.join(images) if images else ''", " bad.append(f'{name}:{found}')", "missing = sorted(required - seen)", "bad.extend(f'{name}:' for name in missing)", "print('\\n'.join(bad))", "PY", " )", " if [ -n \"$bad_manifest_images\" ]; then", " printf 'code_queue_manifest_image_preflight_failed image=%s\\n%s\\n' \"$image\" \"$bad_manifest_images\" >&2", " exit 1", " fi", " echo code_queue_manifest_image_preflight=ok image=$image", "fi", ] : []; return [ "set -euo pipefail", ...rootAccessPrelude(), `image=${shellQuote(image)}`, `archive=${shellQuote(archive)}`, `manifest=${shellQuote(manifest)}`, "docker image inspect \"$image\" >/dev/null", ...codeQueueManifestPreflight, "rm -f \"$archive\"", "docker save \"$image\" -o \"$archive\"", `root_exec ctr --address ${shellQuote(nativeK3sCtrAddress)} -n k8s.io images import "$archive"`, "rm -f \"$archive\"", `if ! root_exec ctr --address ${shellQuote(nativeK3sCtrAddress)} -n k8s.io images ls | grep -F "$image"; then`, " printf 'native_k3s_containerd_image_missing=%s\\n' \"$image\" >&2", " exit 1", "fi", "printf 'native_k3s_containerd_image_present=%s\\n' \"$image\"", ].join("\n"); } function cleanupLegacyDirectCodeQueueScript(service: UniDeskMicroserviceConfig): string { if (service.id !== "code-queue") return ""; if (isDevK3sDeployService(service)) return ""; return [ "set -euo pipefail", "container=code-queue-backend", "if docker ps -a --format '{{.Names}}' | grep -Fx \"$container\" >/dev/null; then", " docker update --restart=no \"$container\" >/dev/null 2>&1 || true", ` compose_file=${shellQuote(`${targetWorkDir(service)}/src/components/microservices/code-queue/docker-compose.d601.yml`)}`, " if [ -f \"$compose_file\" ]; then", " docker compose -p code-queue -f \"$compose_file\" down --remove-orphans", " else", " docker rm -f \"$container\"", " fi", " echo stopped_legacy_direct_code_queue=$container", "else", " echo stopped_legacy_direct_code_queue=not-present", "fi", "if docker ps --format '{{.Names}}' | grep -Fx \"$container\" >/dev/null; then", " echo legacy_direct_code_queue_still_running=$container >&2", " exit 1", "fi", ].join("\n"); } function k8sNamespaceForService(service: UniDeskMicroserviceConfig): string { return service.deployment.namespace ?? k8sNamespace; } function k8sDeploymentsForService(service: UniDeskMicroserviceConfig): string[] { if (isDevK3sDeployService(service) && service.id === "code-queue") { return ["d601-dev-provider-egress-proxy", "code-queue-scheduler-dev", "code-queue-read-dev", "code-queue-write-dev"]; } if (service.id === "code-queue") return ["d601-provider-egress-proxy", "d601-tcp-egress-gateway", "code-queue", "code-queue-read", "code-queue-write"]; return [service.repository.composeService]; } function applyK8sScript(service: UniDeskMicroserviceConfig): string { const manifest = `${targetWorkDir(service)}/${k8sManifestPath(service)}`; const namespace = k8sNamespaceForService(service); const cleanup = service.id === "code-queue" && !isDevK3sDeployService(service) ? [ `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} delete endpointslice d601-provider-egress-proxy --ignore-not-found`, `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} delete deployment code-queue-d518 --ignore-not-found`, `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} delete service code-queue-d518 --ignore-not-found`, ].join("\n") : ""; return [ cleanup, `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl apply -f ${shellQuote(manifest)}`, ].filter(Boolean).join("\n"); } function verifyK8sImagesScript(service: UniDeskMicroserviceConfig): string { const namespace = k8sNamespaceForService(service); const image = buildImageTag(service); const deployments = k8sDeploymentsForService(service); return [ "set -euo pipefail", `image=${shellQuote(image)}`, `namespace=${shellQuote(namespace)}`, `deployments=(${deployments.map(shellQuote).join(" ")})`, "for deployment in \"${deployments[@]}\"; do", ` actual=$(KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n "$namespace" get deployment "$deployment" -o jsonpath='{range .spec.template.spec.containers[*]}{.image}{\"\\n\"}{end}')`, " if [ -z \"$actual\" ]; then", " echo \"deployment_image_missing=$deployment\" >&2", " exit 1", " fi", " while IFS= read -r actual_image; do", " [ -z \"$actual_image\" ] && continue", " if [ \"$actual_image\" != \"$image\" ]; then", " printf 'deployment_image_mismatch deployment=%s expected=%s actual=%s\\n' \"$deployment\" \"$image\" \"$actual_image\" >&2", " exit 1", " fi", " done < `deployment/${name}`); const namespace = k8sNamespaceForService(service); const envPairs = [ `UNIDESK_DEPLOY_SERVICE_ID=${service.id}`, `UNIDESK_DEPLOY_REPO=${desired.repo}`, `UNIDESK_DEPLOY_COMMIT=${resolvedCommit}`, `UNIDESK_DEPLOY_REQUESTED_COMMIT=${desired.commitId}`, ...(service.id === "code-queue" ? [ `CODE_QUEUE_DEPLOY_COMMIT=${resolvedCommit}`, `CODE_QUEUE_DEPLOY_REQUESTED_COMMIT=${desired.commitId}`, ] : []), ]; const annotatePairs = [ `unidesk.ai/deploy-service-id=${service.id}`, `unidesk.ai/deploy-repo=${desired.repo}`, `unidesk.ai/deploy-commit=${resolvedCommit}`, `unidesk.ai/deploy-requested-commit=${desired.commitId}`, ]; return [ "set -euo pipefail", `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} set env ${deployments.map(shellQuote).join(" ")} ${envPairs.map(shellQuote).join(" ")}`, `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} annotate ${deployments.map(shellQuote).join(" ")} ${annotatePairs.map(shellQuote).join(" ")} --overwrite`, `actual=$(KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} get deployment ${shellQuote(service.repository.composeService)} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}')`, `test "$actual" = ${shellQuote(resolvedCommit)}`, "printf 'k8s_deploy_commit=%s\\n' \"$actual\"", ].join("\n"); } function rolloutK8sScript(service: UniDeskMicroserviceConfig): string { const deployments = k8sDeploymentsForService(service); const namespace = k8sNamespaceForService(service); return [ "set -euo pipefail", `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} rollout restart ${deployments.map((name) => shellQuote(`deployment/${name}`)).join(" ")}`, ...deployments.map((name) => `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} rollout status ${shellQuote(`deployment/${name}`)} --timeout=180s`), `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} get deploy ${deployments.map(shellQuote).join(" ")} -o wide`, ].join("\n"); } function k8sCommitProbeScript(service: UniDeskMicroserviceConfig): string { const namespace = k8sNamespaceForService(service); return [ "set -euo pipefail", `commit=$(KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl -n ${shellQuote(namespace)} get deployment ${shellQuote(service.repository.composeService)} -o jsonpath='{.metadata.annotations.unidesk\\.ai/deploy-commit}' 2>/dev/null || true)`, "printf '%s\\n' \"$commit\"", ].join("\n"); } function dockerCommitProbeScript(service: UniDeskMicroserviceConfig): string { return [ "set -euo pipefail", `container=${shellQuote(service.repository.containerName)}`, "cid=$(docker ps -q -f name=\"^/${container}$\" | head -1)", "if [ -z \"$cid\" ]; then exit 0; fi", "image_id=$(docker inspect -f '{{.Image}}' \"$cid\")", "docker image inspect -f '{{ index .Config.Labels \"unidesk.ai/source-commit\" }}' \"$image_id\" 2>/dev/null || true", ].join("\n"); } function healthDeployCommit(body: Record | null): string | null { const deploy = asRecord(body?.deploy); const commit = asString(deploy?.commit).toLowerCase(); return commit.length > 0 ? commit : null; } function healthSummary(response: unknown): Record { const record = asRecord(response); const body = asRecord(record?.body); return { ok: record?.ok ?? false, status: record?.status ?? null, body: body === null ? null : { ok: body.ok ?? null, service: body.service ?? null, instanceId: body.instanceId ?? null, deploy: body.deploy ?? null, status: body.status ?? null, startedAt: body.startedAt ?? null, }, }; } async function directHttpJson(url: string, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); const text = await response.text(); let body: unknown = null; try { body = text.length > 0 ? JSON.parse(text) : null; } catch { body = { text }; } return { ok: response.ok, status: response.status, body }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } finally { clearTimeout(timer); } } function devK3sServiceHealthScript(service: UniDeskMicroserviceConfig): string { const namespace = k8sNamespaceForService(service); const serviceName = service.repository.composeService; const path = service.backend.healthPath; const port = service.backend.nodePort; return [ "set -euo pipefail", `namespace=${shellQuote(namespace)}`, `service_name=${shellQuote(serviceName)}`, `path=${shellQuote(path)}`, `port=${shellQuote(String(port))}`, "case \"$path\" in /*) ;; *) path=\"/$path\" ;; esac", "proxy_path=\"/api/v1/namespaces/$namespace/services/http:$service_name:$port/proxy$path\"", "tmp_health=$(mktemp)", "trap 'rm -f \"$tmp_health\"' EXIT", `KUBECONFIG=${shellQuote(k8sKubeconfig)} kubectl get --raw "$proxy_path" > "$tmp_health"`, "python3 - \"$tmp_health\" <<'PY'", "import json", "import sys", "", "text = open(sys.argv[1], encoding='utf-8', errors='replace').read()", "try:", " body = json.loads(text)", "except Exception:", " print(text[:4000])", " raise SystemExit(0)", "", "def compact(mapping, keys):", " if not isinstance(mapping, dict):", " return None", " return {key: mapping.get(key) for key in keys if key in mapping}", "", "summary = compact(body, ['ok', 'service', 'instanceId', 'role', 'environment', 'namespace', 'databaseName', 'serviceId', 'status', 'startedAt', 'deployRef']) or {}", "deploy = compact(body.get('deploy'), ['repo', 'commit', 'requestedCommit', 'serviceId'])", "if deploy is not None:", " summary['deploy'] = deploy", "queue = body.get('queue')", "if isinstance(queue, dict):", " queue_summary = compact(queue, ['total', 'queueCount', 'defaultQueueId', 'processing']) or {}", " storage = compact(queue.get('storage'), ['primary', 'postgresReady'])", " if storage is not None:", " queue_summary['storage'] = storage", " summary['queue'] = queue_summary", "print(json.dumps(summary, ensure_ascii=False, separators=(',', ':')))", "PY", ].join("\n"); } async function serviceHealth(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise { if (isCoreDeployService(service)) { return await directHttpJson(`${service.backend.nodeBaseUrl}${service.backend.healthPath}`, service.backend.timeoutMs); } if (isDevK3sDeployService(service)) { const result = await runTargetCommand(config, service, devK3sServiceHealthScript(service), "/home/ubuntu", 30_000, 20_000); const stdout = result.stdout.trim(); let body: unknown = null; try { body = stdout.length > 0 ? parseJsonObjectFromText(stdout) : null; } catch { body = null; } if (body === null && stdout.length > 0) body = { text: stdout }; return { ok: result.ok, status: result.ok ? 200 : 502, body, raw: result.raw, error: result.ok ? undefined : result.stderr || result.stdout || "dev k3s service health failed", }; } return coreInternalFetch(`/api/microservices/${encodeURIComponent(service.id)}/health`); } function commitMatches(actual: string | null, desired: string): boolean { if (actual === null || actual.length === 0) return false; const normalized = actual.toLowerCase(); return normalized === desired.toLowerCase() || (desired.length < 40 && normalized.startsWith(desired.toLowerCase())); } function runtimeCommitVerified( service: UniDeskMicroserviceConfig, healthCommit: string | null, imageCommit: string | null, orchestratorCommit: string | null, desired: string, ): boolean { if (service.deployment.mode === "k3sctl-managed") return commitMatches(orchestratorCommit, desired); if (healthCommit !== null && healthCommit.length > 0 && !commitMatches(healthCommit, desired)) return false; return commitMatches(imageCommit, desired); } function runtimeCurrentCommit( service: UniDeskMicroserviceConfig, healthCommit: string | null, imageCommit: string | null, orchestratorCommit: string | null, ): string | null { if (service.deployment.mode === "k3sctl-managed") return orchestratorCommit ?? imageCommit; return healthCommit ?? imageCommit ?? orchestratorCommit; } function coreBody(response: unknown): Record | null { const record = asRecord(response); return asRecord(record?.body); } async function dispatchSsh( config: UniDeskConfig, providerId: string, command: string, cwd: string | null, waitMs = shortDispatchWaitMs, remoteTimeoutMs = shortRemoteTimeoutMs, ): Promise { const dispatchResponse = coreInternalFetch("/api/dispatch", { method: "POST", body: { providerId, command: "host.ssh", payload: { source: "deploy-reconciler", mode: "exec", command, timeoutMs: remoteTimeoutMs, ...(cwd === null ? {} : { cwd }), }, }, }); const dispatchBody = coreBody(dispatchResponse); const taskId = asString(dispatchBody?.taskId); if (dispatchBody?.ok !== true || taskId.length === 0) { return { ok: false, taskId: taskId || null, status: null, stdout: "", stderr: asString(dispatchBody?.error) || "dispatch did not return a task id", exitCode: null, raw: dispatchResponse, }; } const effectiveWaitMs = Math.max(waitMs, Math.min(remoteTimeoutMs + providerDispatchCompletionLagMs, 120_000)); const deadline = Date.now() + effectiveWaitMs; let latest: unknown = null; while (Date.now() < deadline) { latest = coreInternalFetch(`/api/tasks/${encodeURIComponent(taskId)}`); const task = asRecord(coreBody(latest)?.task); const status = asString(task?.status); if (status === "succeeded" || status === "failed") { const result = asRecord(task?.result); const exitCode = typeof result?.exitCode === "number" ? result.exitCode : null; const stdout = asString(result?.stdout); const stderr = asString(result?.stderr); return { ok: status === "succeeded" && (exitCode === null || exitCode === 0), taskId, status, stdout, stderr, exitCode, raw: task, }; } await Bun.sleep(500); } return { ok: false, taskId, status: "timeout", stdout: "", stderr: `host.ssh task ${taskId} did not finish within ${effectiveWaitMs}ms`, exitCode: null, raw: latest, }; } async function runTargetCommand(config: UniDeskConfig, service: UniDeskMicroserviceConfig, command: string, cwd: string | null, waitMs = 60_000, remoteTimeoutMs = 45_000): Promise { if (targetIsMain(service)) { const result = runCommand(["bash", "-lc", command], cwd ?? repoRoot); return { ok: result.exitCode === 0, taskId: null, status: result.exitCode === 0 ? "succeeded" : "failed", stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, raw: result, }; } return await dispatchSsh(config, service.providerId, command, cwd, waitMs, remoteTimeoutMs); } function splitFixed(value: string, size: number): string[] { const chunks: string[] = []; for (let index = 0; index < value.length; index += size) chunks.push(value.slice(index, index + size)); return chunks; } async function uploadRemoteShellScript( config: UniDeskConfig, service: UniDeskMicroserviceConfig, script: string, cwd: string, name: string, ): Promise { const remotePath = `/tmp/unidesk-deploy-${safeId(service.id)}-${safeId(name)}-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}.sh`; const b64Path = `${remotePath}.b64`; const raw: unknown[] = []; const init = await runTargetCommand(config, service, `umask 077; rm -f ${shellQuote(remotePath)} ${shellQuote(b64Path)}; : > ${shellQuote(b64Path)}`, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs); raw.push(init.raw); if (!init.ok) return { ok: false, path: remotePath, error: init.stderr || init.stdout || "failed to initialize remote script upload", raw }; const encoded = Buffer.from(script, "utf8").toString("base64"); for (const chunk of splitFixed(encoded, 2400)) { const append = await runTargetCommand(config, service, `printf %s ${shellQuote(chunk)} >> ${shellQuote(b64Path)}`, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs); raw.push(append.raw); if (!append.ok) return { ok: false, path: remotePath, error: append.stderr || append.stdout || "failed to append remote script chunk", raw }; } const finalize = await runTargetCommand(config, service, `base64 -d ${shellQuote(b64Path)} > ${shellQuote(remotePath)}; chmod 700 ${shellQuote(remotePath)}; rm -f ${shellQuote(b64Path)}; wc -c ${shellQuote(remotePath)}`, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs); raw.push(finalize.raw); if (!finalize.ok) return { ok: false, path: remotePath, error: finalize.stderr || finalize.stdout || "failed to finalize remote script upload", raw }; return { ok: true, path: remotePath, error: "", raw }; } async function launchRemoteBackground( config: UniDeskConfig, service: UniDeskMicroserviceConfig, shellScript: string, cwd: string, logFile: string, sentinelFile: string, ): Promise<{ ok: boolean; pid: string; raw: unknown; error: string }> { const wrapped = [ `bash -lc ${shellQuote(shellScript)}`, "code=$?", `printf '%s\\n' "$code" > ${shellQuote(sentinelFile)}`, "exit \"$code\"", ].join("; "); const command = [ `rm -f ${shellQuote(sentinelFile)} ${shellQuote(logFile)}`, `nohup bash -lc ${shellQuote(wrapped)} > ${shellQuote(logFile)} 2>&1 < /dev/null & echo $!`, ].join("; "); const result = await runTargetCommand(config, service, command, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs); const pid = result.stdout.trim().split("\n").pop()?.trim() ?? ""; if (!result.ok || !/^\d+$/u.test(pid)) { return { ok: false, pid: "", raw: result.raw, error: result.stderr || result.stdout || "failed to launch background command" }; } return { ok: true, pid, raw: result.raw, error: "" }; } async function pollRemoteBackground(config: UniDeskConfig, service: UniDeskMicroserviceConfig, cwd: string, logFile: string, sentinelFile: string): Promise { const command = [ `if [ -f ${shellQuote(sentinelFile)} ]; then printf 'SENTINEL:%s\\n' "$(cat ${shellQuote(sentinelFile)} 2>/dev/null || true)"; else echo RUNNING; fi`, `tail -n 100 ${shellQuote(logFile)} 2>/dev/null | tr -d '\\000' | LC_ALL=C sed 's/[^[:print:]\t]//g' || true`, ].join("; "); const result = await runTargetCommand(config, service, command, cwd, shortDispatchWaitMs, shortRemoteTimeoutMs); const stdout = result.stdout.trimEnd(); const [head = "", ...rest] = stdout.split("\n"); if (head.startsWith("SENTINEL:")) { const rawExitCode = head.slice("SENTINEL:".length).trim(); const exitCode = /^\d+$/u.test(rawExitCode) ? Number(rawExitCode) : null; return { done: true, exitCode, logTail: rest.join("\n").trim(), raw: result.raw }; } return { done: false, exitCode: null, logTail: rest.join("\n").trim(), raw: result.raw }; } async function step( config: UniDeskConfig, service: UniDeskMicroserviceConfig, name: string, command: string, cwd: string | null, timeoutMs: number, background = false, ): Promise { const startedAt = nowIso(); const startedMs = Date.now(); progressLine(name, "start", { serviceId: service.id, providerId: service.providerId, cwd }); if (!background || targetIsMain(service)) { const result = await runTargetCommand(config, service, command, cwd, timeoutMs, timeoutMs); const detail = compactTail([result.stdout, result.stderr].filter(Boolean).join("\n"), 2000); const ok = result.ok; progressLine(name, ok ? "succeeded" : "failed", { serviceId: service.id, elapsedMs: elapsedMs(startedMs), detail }); return { step: name, ok, detail: ok ? detail || `completed in ${elapsedMs(startedMs)}ms` : detail || "command failed", startedAt, finishedAt: nowIso(), raw: result.raw }; } const runId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`; const logFile = `/tmp/unidesk-deploy-${safeId(service.id)}-${name}-${runId}.log`; const sentinelFile = `/tmp/unidesk-deploy-${safeId(service.id)}-${name}-${runId}.done`; let launchCommand = command; if (launchCommand.length > 1800) { const upload = await uploadRemoteShellScript(config, service, command, cwd ?? "/home/ubuntu", name); if (!upload.ok) return { step: name, ok: false, detail: upload.error, startedAt, finishedAt: nowIso(), raw: upload.raw }; launchCommand = `bash ${shellQuote(upload.path)}`; progressLine(name, "remote script uploaded", { serviceId: service.id, path: upload.path, bytes: command.length }); } const launch = await launchRemoteBackground(config, service, launchCommand, cwd ?? "/home/ubuntu", logFile, sentinelFile); if (!launch.ok) return { step: name, ok: false, detail: launch.error, startedAt, finishedAt: nowIso(), raw: launch.raw }; progressLine(name, "remote background started", { serviceId: service.id, pid: launch.pid, logFile }); const deadline = Date.now() + timeoutMs; let lastTail = ""; while (Date.now() < deadline) { await Bun.sleep(pollIntervalMs); const poll = await pollRemoteBackground(config, service, cwd ?? "/home/ubuntu", logFile, sentinelFile); const tail = compactTail(poll.logTail, 1800); if (tail.length > 0 && tail !== lastTail) { lastTail = tail; progressLine(name, "remote log tail", { serviceId: service.id, elapsedMs: elapsedMs(startedMs), tail }); } if (poll.done) { const ok = poll.exitCode === 0; const detail = ok ? `completed in ${elapsedMs(startedMs)}ms; log=${logFile}` : `failed with exit ${poll.exitCode}; log=${logFile}; tail=${compactTail(poll.logTail)}`; progressLine(name, ok ? "succeeded" : "failed", { serviceId: service.id, detail }); return { step: name, ok, detail, startedAt, finishedAt: nowIso(), raw: poll.raw }; } } return { step: name, ok: false, detail: `timed out after ${timeoutMs}ms`, startedAt, finishedAt: nowIso(), raw: null }; } async function readDockerImageCommit(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise { if (isDevK3sDeployService(service)) return null; const result = await runTargetCommand(config, service, dockerCommitProbeScript(service), targetIsMain(service) ? repoRoot : "/home/ubuntu", 30_000, 20_000); const commit = parseFullCommit(result.stdout); return commit.length > 0 ? commit : null; } async function readK8sCommit(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise { if (service.deployment.mode !== "k3sctl-managed") return null; const result = await runTargetCommand(config, service, k8sCommitProbeScript(service), "/home/ubuntu", 30_000, 20_000); const commit = parseFullCommit(result.stdout); return commit.length > 0 ? commit : null; } async function readRuntimeState(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService): Promise { const reason = unsupportedReason(service); const health = await serviceHealth(config, service); const healthBody = coreBody(health); const healthCommit = healthDeployCommit(healthBody); const healthRecord = asRecord(health); const healthOk = healthRecord?.ok === true && healthBody?.ok !== false; const [imageCommit, orchestratorCommit] = await Promise.all([ readDockerImageCommit(config, service).catch(() => null), readK8sCommit(config, service).catch(() => null), ]); const currentCommit = runtimeCurrentCommit(service, healthCommit, imageCommit, orchestratorCommit); return { serviceId: service.id, ok: reason === null, supported: reason === null, reason, desiredRepo: desired.repo, desiredCommit: desired.commitId, providerId: service.providerId, deploymentMode: service.deployment.mode, currentCommit, healthCommit, imageCommit, orchestratorCommit, healthOk, upToDate: reason === null && healthOk && runtimeCommitVerified(service, healthCommit, imageCommit, orchestratorCommit, desired.commitId), raw: { health: healthSummary(health) }, }; } async function healthVerify(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService, resolvedCommit: string, timeoutMs: number): Promise { const startedAt = nowIso(); const startedMs = Date.now(); const deadline = Date.now() + timeoutMs; let latest: ServiceRuntimeState | null = null; while (Date.now() < deadline) { latest = await readRuntimeState(config, service, { ...desired, commitId: resolvedCommit }); const commitOk = runtimeCommitVerified(service, latest.healthCommit, latest.imageCommit, latest.orchestratorCommit, resolvedCommit); const ok = latest.healthOk && commitOk; progressLine("live-health", "probe", { serviceId: service.id, ok, healthOk: latest.healthOk, expectedCommit: resolvedCommit, healthCommit: latest.healthCommit, imageCommit: latest.imageCommit, orchestratorCommit: latest.orchestratorCommit }); if (ok) { return { step: "live-health", ok: true, detail: `service ${service.id} health passed with deployed commit ${resolvedCommit} in ${elapsedMs(startedMs)}ms`, startedAt, finishedAt: nowIso(), raw: latest, }; } await Bun.sleep(3_000); } return { step: "live-health", ok: false, detail: `service ${service.id} did not report expected commit ${resolvedCommit} within ${timeoutMs}ms`, startedAt, finishedAt: nowIso(), raw: latest, }; } function pushStep(steps: StepResult[], result: StepResult): boolean { steps.push(result); return result.ok; } async function ensureGithubSshIdentityStep(config: UniDeskConfig, service: UniDeskMicroserviceConfig): Promise { const startedAt = nowIso(); const startedMs = Date.now(); progressLine("github-ssh-identity", "start", { serviceId: service.id, providerId: service.providerId }); try { const result = await ensureGithubSshIdentityForProvider(config, service.providerId); progressLine("github-ssh-identity", result.ok ? "succeeded" : "failed", { serviceId: service.id, providerId: service.providerId, elapsedMs: elapsedMs(startedMs), fingerprint: result.fingerprint, seededFromLocal: result.seededFromLocal, detail: result.ok ? result.detail : compactTail(result.detail, 1200), }); return { step: "github-ssh-identity", ok: result.ok, detail: result.detail, startedAt, finishedAt: nowIso(), raw: result.raw, }; } catch (error) { const detail = error instanceof Error ? error.message : String(error); progressLine("github-ssh-identity", "failed", { serviceId: service.id, providerId: service.providerId, elapsedMs: elapsedMs(startedMs), detail: compactTail(detail, 1200), }); return { step: "github-ssh-identity", ok: false, detail, startedAt, finishedAt: nowIso(), raw: null }; } } async function applyOneService(config: UniDeskConfig, service: UniDeskMicroserviceConfig, desired: DeployManifestService, options: DeployOptions): Promise> { const steps: StepResult[] = []; const startedAt = nowIso(); if (!options.dryRun && isD601MaintenanceDeployBlocked(service)) { return { ok: false, serviceId: service.id, skipped: true, reason: `D601 target-side deployment is allowed only for k3sctl-adapter and dev backend-core/frontend; ${service.id} is not enabled. Use ci run-dev-e2e for smoke verification.`, steps, }; } const reason = unsupportedReason(service); if (reason !== null) return { ok: false, serviceId: service.id, skipped: true, reason, steps }; const before = await readRuntimeState(config, service, desired); if (!options.force && before.upToDate) return { ok: true, serviceId: service.id, action: "noop", before, steps }; if (options.dryRun) return { ok: true, serviceId: service.id, action: "would-deploy", before, steps }; const runId = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`; const exportDir = targetExportDir(service, runId); if (!targetIsMain(service) && isUnideskRepo(desired.repo)) { const identity = await ensureGithubSshIdentityStep(config, service); if (!pushStep(steps, identity)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), before, steps }; } const prepare = await step(config, service, "prepare-source", prepareSourceScript(service, desired, exportDir), targetIsMain(service) ? repoRoot : "/home/ubuntu", Math.min(options.timeoutMs, 180_000), !targetIsMain(service)); if (!pushStep(steps, prepare)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), before, steps }; const resolvedCommit = parseFullCommit(prepare.detail) || parseFullCommit(JSON.stringify(prepare.raw)); if (resolvedCommit.length !== 40) { const failure = { step: "resolve-commit", ok: false, detail: "prepare-source did not expose a full 40-char commit", startedAt: nowIso(), finishedAt: nowIso(), raw: prepare }; steps.push(failure); return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), before, steps }; } const sync = await step(config, service, "sync-source", syncSourceScript(service, exportDir), targetIsMain(service) ? repoRoot : "/home/ubuntu", 90_000, !targetIsMain(service)); if (!pushStep(steps, sync)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const controlManifestSyncScript = syncK8sControlManifestsScript(service); if (controlManifestSyncScript.length > 0) { const controlManifestStep = isDevK3sDeployService(service) ? "verify-target-k3s-manifest" : "sync-k3s-control-manifests"; const controlManifestSync = await step(config, service, controlManifestStep, controlManifestSyncScript, targetIsMain(service) ? repoRoot : "/home/ubuntu", 90_000, !targetIsMain(service)); if (!pushStep(steps, controlManifestSync)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; } if (isDevK3sDeployService(service)) { const patchManifest = await step(config, service, "patch-dev-k3s-manifest", patchDevK3sManifestScript(service, desired, resolvedCommit), targetWorkDir(service), 60_000, false); if (!pushStep(steps, patchManifest)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; } const buildScript = isDirectComposeDeployMode(service) ? buildDirectImageScript(service, desired, resolvedCommit) : buildImageScript(service, desired, resolvedCommit); const build = await step(config, service, "docker-build", buildScript, targetWorkDir(service), dockerBuildTimeoutMs(service, options), !targetIsMain(service)); if (!pushStep(steps, build)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; if (service.deployment.mode === "k3sctl-managed") { const nativeK3s = await step(config, service, "ensure-native-k3s", ensureNativeK3sScript(), "/home/ubuntu", 600_000, true); if (!pushStep(steps, nativeK3s)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const imageImport = await step(config, service, "import-k3s-image", importK3sImageScript(service), targetWorkDir(service), Math.min(options.timeoutMs, 900_000), true); if (!pushStep(steps, imageImport)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const apply = await step(config, service, "kubectl-apply", applyK8sScript(service), targetWorkDir(service), 60_000, false); if (!pushStep(steps, apply)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const k8sImages = await step(config, service, "k8s-image-preflight", verifyK8sImagesScript(service), targetWorkDir(service), 60_000, false); if (!pushStep(steps, k8sImages)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const stamp = await step(config, service, "stamp-deploy-commit", stampK8sScript(service, desired, resolvedCommit), targetWorkDir(service), 60_000, false); if (!pushStep(steps, stamp)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const rollout = await step(config, service, "rollout", rolloutK8sScript(service), targetWorkDir(service), 240_000, true); if (!pushStep(steps, rollout)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const legacyCleanupScript = cleanupLegacyDirectCodeQueueScript(service); if (legacyCleanupScript.length > 0) { const cleanup = await step(config, service, "legacy-direct-cleanup", legacyCleanupScript, targetWorkDir(service), 90_000, false); if (!pushStep(steps, cleanup)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; } } else { const deploy = await step(config, service, "compose-up", composeDeployScript(service, desired, resolvedCommit), targetIsMain(service) ? repoRoot : targetWorkDir(service), 180_000, !targetIsMain(service)); if (!pushStep(steps, deploy)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; const imageVerify = await step(config, service, "image-label-verify", imageLabelVerifyScript(service, resolvedCommit), targetIsMain(service) ? repoRoot : targetWorkDir(service), 60_000, false); if (!pushStep(steps, imageVerify)) return { ok: false, serviceId: service.id, startedAt, finishedAt: nowIso(), resolvedCommit, before, steps }; } const health = await healthVerify(config, service, desired, resolvedCommit, 90_000); steps.push(health); return { ok: health.ok, serviceId: service.id, action: "deployed", startedAt, finishedAt: nowIso(), resolvedCommit, before, after: health.raw, steps, }; } async function checkOrPlan(config: UniDeskConfig, manifest: DeployManifest, options: DeployOptions, action: "check" | "plan"): Promise> { const services = selectServices(config, manifest, options.serviceId); const items = []; for (const item of services) { const state = await readRuntimeState(config, item.config, item.desired); items.push({ ...state, action: action === "plan" ? state.supported ? state.upToDate ? "noop" : "deploy" : "unsupported" : undefined, }); } return { ok: items.every((item) => item.supported && (action === "plan" || item.healthOk)), action, file: options.file, services: items, }; } function environmentDryRunPlan( manifest: DeployManifest, source: DeployEnvironmentManifestSource, options: DeployOptions, action: "check" | "plan", ): Record { const environment = options.environment; if (environment === null) throw new Error("environment dry-run requires --env"); const target = deployEnvironmentTargets[environment]; const services = options.serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === options.serviceId); if (options.serviceId !== null && services.length === 0) { throw new Error(`deploy manifest ${source.ref}:deploy.json does not contain service: ${options.serviceId}`); } const fingerprint = databaseFingerprint(target); return { ok: true, action, mode: "environment-ref-dry-run", dryRun: true, mutatesRuntime: false, environment, gitRef: source.ref, environmentPath: `environments.${environment}`, manifest: { source: source.source, path: source.path, ref: source.ref, remote: source.remote, branch: source.branch, commit: source.commit, blob: source.blob, environment: manifest.environment, environmentPath: `environments.${environment}`, fetchedAt: source.fetchedAt, }, target: environmentTargetSummary(target), localCompatibility: { localFileMode: false, deployJsonFromWorktree: false, dirtyWorktreeUsed: false, }, services: services.map((service) => ({ id: service.id, repo: service.repo, commitId: service.commitId, environment, targetNamespace: target.namespace, providerId: target.provider.providerId, databaseFingerprint: fingerprint, })), }; } function unsupportedDevApplyServices(manifest: DeployManifest, serviceId: string | null): string[] { if (manifest.environment !== "dev") return []; const services = serviceId === null ? manifest.services : manifest.services.filter((service) => service.id === serviceId); return services.map((service) => service.id).filter((id) => !devApplySupportedServiceIds.has(id)); } function blockedD601MaintenanceDeployServices(config: UniDeskConfig, manifest: DeployManifest, serviceId: string | null): string[] { return selectServices(config, manifest, serviceId) .map((item) => item.config) .filter(isD601MaintenanceDeployBlocked) .map((service) => service.id); } function d601MaintenanceDeployBlockMessage(blocked: string[]): string { return `D601 target-side deployment is enabled only for k3sctl-adapter and dev backend-core/frontend; blocked services: ${blocked.join(", ")}. Use ci run-dev-e2e for dev smoke verification.`; } async function runApplyNow(config: UniDeskConfig, manifest: DeployManifest, options: DeployOptions): Promise> { const selected = selectServices(config, manifest, options.serviceId); const startedAt = nowIso(); const results = []; for (const item of selected) { progressLine("service", "reconcile", { serviceId: item.config.id, providerId: item.config.providerId, mode: item.config.deployment.mode }); results.push(await applyOneService(config, item.config, item.desired, options)); const latest = results.at(-1) as Record; if (latest.ok !== true) break; } return { ok: results.every((result) => result.ok === true), action: "apply", file: options.file, dryRun: options.dryRun, startedAt, finishedAt: nowIso(), results, }; } function applyJob(config: UniDeskConfig, args: string[], options: DeployOptions): Record { const runArgs = args.includes("--run-now") ? args : [...args, "--run-now"]; const command = [process.execPath, rootPath("scripts", "cli.ts"), "deploy", ...runArgs]; const source = options.environment === null ? options.file : `${deployEnvironmentTargets[options.environment].gitRef}:deploy.json#environments.${options.environment}`; const job = startJob("deploy_apply", command, `Reconcile services from ${source}${options.serviceId === null ? "" : ` service=${options.serviceId}`}`); 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: "Deployment continues in the background: target-side fetch, one-shot proxied build, deploy, stamp, and live health verification.", configProject: config.project.name, }; } export async function runDeployCommand(config: UniDeskConfig | null, args: string[]): Promise { const [actionRaw = "check"] = args; if (isHelpArg(actionRaw) || args.slice(1).some(isHelpArg)) return deployHelp(isHelpArg(actionRaw) ? undefined : actionRaw); if (!["check", "plan", "apply"].includes(actionRaw)) throw new Error("deploy command must be one of: check, plan, apply"); const action = actionRaw as DeployAction; const options = parseOptions(args.slice(1)); if (options.environment !== null) { const { manifest, source } = readEnvironmentDeployManifest(options.environment); if (action === "check" || action === "plan") return environmentDryRunPlan(manifest, source, options, action); if (options.environment !== "dev") throw new Error("deploy apply --env prod is not enabled yet"); const unsupported = unsupportedDevApplyServices(manifest, options.serviceId); if (unsupported.length > 0) { throw new Error(`deploy apply --env dev currently supports only backend-core and frontend; unsupported selected services: ${unsupported.join(", ")}. Use ci run-dev-e2e for smoke verification.`); } if (config === null) throw new Error("deploy apply --env dev requires config.json"); if (!options.dryRun) { const blocked = blockedD601MaintenanceDeployServices(config, manifest, options.serviceId); if (blocked.length > 0) throw new Error(d601MaintenanceDeployBlockMessage(blocked)); } if (!options.runNow) return applyJob(config, args, options); return await runApplyNow(config, manifest, options); } if (config === null) throw new Error("deploy local manifest mode requires config.json"); const manifest = resolveManifestCommits(await readDeployManifest(options.file), options.serviceId); if (action === "check" || action === "plan") return await checkOrPlan(config, manifest, options, action); if (!options.dryRun) { const blocked = blockedD601MaintenanceDeployServices(config, manifest, options.serviceId); if (blocked.length > 0) throw new Error(d601MaintenanceDeployBlockMessage(blocked)); } if (!options.runNow) return applyJob(config, args, options); return await runApplyNow(config, manifest, options); } export async function runCodeQueueDeployCompatCommand(_config: UniDeskConfig, args: string[]): Promise { if (args.includes("--skip-build")) throw new Error("codex deploy is disabled; --skip-build is not supported"); const providerId = optionValue(args, ["--provider-id", "--provider"]) ?? "D601"; if (providerId !== "D601") throw new Error(`codex deploy compatibility path only supports D601; got ${providerId}`); throw new Error("codex deploy is disabled because D601 maintenance-channel direct deployment must not deploy Code Queue. Current dev automation is CI-only; use ci run-dev-e2e for dev smoke verification."); }