fix: resolve deploy commit ids before apply
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user