fix: resolve deploy commit ids before apply

This commit is contained in:
Codex
2026-05-16 12:52:52 +00:00
parent 9a451b817b
commit da25f5d2d6
2 changed files with 101 additions and 2 deletions
+1 -1
View File
@@ -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.
+100 -1
View File
@@ -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<typeof runCommand>, 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<typeof runCommand> {
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);