diff --git a/docs/reference/deploy.md b/docs/reference/deploy.md index e791d17a..e8d471be 100644 --- a/docs/reference/deploy.md +++ b/docs/reference/deploy.md @@ -19,7 +19,7 @@ The root `deploy.json` is intentionally minimal: } ``` -`deploy.json` must not contain provider IDs, ports, compose service names, Kubernetes namespace, health paths, environment variables, Dockerfile paths or build commands. The deploy reconciler joins each `id` with `config.json.microservices[]` and existing k3s manifests to resolve those details. A service listed in `deploy.json` but missing from `config.json` is an error. A service with no Dockerfile source artifact is reported as unsupported rather than silently skipped. +`deploy.json` must not contain provider IDs, ports, compose service names, Kubernetes namespace, health paths, environment variables, Dockerfile paths or build commands. The deploy reconciler joins each `id` with `config.json.microservices[]` and existing k3s manifests to resolve those details. A service listed in `deploy.json` but missing from `config.json` is an error. A service with no Dockerfile source artifact is reported as unsupported rather than silently skipped. `commitId` may be a unique pushed short SHA or a full SHA; every deploy command resolves it through the remote repository to a full 40-character commit before target-side build or rollout, and fails immediately if the SHA is missing or ambiguous. `config.json.microservices[].repository.commitId` is retained for catalog compatibility, but `deploy.json` is the deployment version authority for the reconciler. diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index c73612df..9887506a 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { runCommand } from "./command"; @@ -158,6 +159,104 @@ function safeId(value: string): string { 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 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 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 resolveDesiredCommit(desired: DeployManifestService): DeployManifestService { + 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 fetch = runCommand([ + "git", + "-C", + cacheDir, + "fetch", + "--no-tags", + "--prune", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + "+refs/tags/*:refs/tags/*", + ], repoRoot); + 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, + 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); @@ -1200,7 +1299,7 @@ export async function runDeployCommand(config: UniDeskConfig, args: string[]): P 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)); - const manifest = readDeployManifest(options.file); + const manifest = resolveManifestCommits(readDeployManifest(options.file), options.serviceId); if (action === "check" || action === "plan") return await checkOrPlan(config, manifest, options, action); if (!options.runNow) return applyJob(config, args, options); return await runApplyNow(config, manifest, options);