fix: rebuild missing v03 env reuse images

This commit is contained in:
root
2026-06-12 19:14:02 +00:00
parent da0ca25152
commit 42b68db978
4 changed files with 182 additions and 9 deletions
+107 -5
View File
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import * as http from "node:http";
import * as https from "node:https";
import path from "node:path";
import { promisify } from "node:util";
@@ -54,6 +56,9 @@ export async function createCiPlan(options = {}) {
const lane = options.lane ?? "v02";
const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab";
const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
const verifyReuseRegistry = options.verifyReuseRegistry ?? booleanEnv(process.env.HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY);
const reuseRegistryProbe = options.reuseRegistryProbe ?? (verifyReuseRegistry ? probeRegistryManifest : null);
const registryProbeTimeoutMs = Number.parseInt(String(options.registryProbeTimeoutMs ?? process.env.HWLAB_CI_PLAN_REGISTRY_PROBE_TIMEOUT_MS ?? 3000), 10);
const deployJson = await readStructuredFileIfPresent(repoRoot, deployConfigPath, {});
const laneConfig = laneDeployConfig(deployJson, lane);
@@ -118,7 +123,18 @@ export async function createCiPlan(options = {}) {
const environmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
const environmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage);
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady : null;
const environmentInputChanged = envReuse ? catalogRecord?.environmentInputHash !== environmentInputHash : null;
const reuseRegistry = envReuse && reuseRegistryProbe && environmentReady
? await reuseRegistryProbe({
serviceId: model.serviceId,
image: environmentImage,
digest: environmentDigest,
registryPrefix,
timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000
})
: null;
const reuseRegistryUnavailable = Boolean(reuseRegistry && reuseRegistry.status !== "present");
const envChanged = envReuse ? Boolean(relevantEnvMatches.length > 0 || !environmentReady || environmentInputChanged || reuseRegistryUnavailable) : null;
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
const componentInputPaths = uniqueSorted([
...model.componentPaths,
@@ -197,7 +213,9 @@ export async function createCiPlan(options = {}) {
runtimeMode: envReuse ? ENV_REUSE_RUNTIME_MODE : "service-image",
envReuse,
envChanged,
environmentInputChanged,
codeChanged,
reuseRegistry,
environmentInputHash,
codeInputHash,
bootRepo: envReuse ? canonicalBootRepo(deployJson, lane) : null,
@@ -208,7 +226,7 @@ export async function createCiPlan(options = {}) {
envChangedPaths: relevantEnvMatches,
codeChangedPaths: relevantCodeMatches,
reason: affected
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, codeChanged })
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, environmentInputChanged, codeChanged, reuseRegistry })
: reasonForUnchanged(globalChange),
reuse: catalogReuse
});
@@ -243,7 +261,8 @@ export async function createCiPlan(options = {}) {
inputs: {
registryPrefix,
baseImage,
serviceCount: services.length
serviceCount: services.length,
verifyReuseRegistry
},
changedPaths: normalizedChangedPaths,
changedPathSummary: globalChange,
@@ -648,9 +667,11 @@ async function gitValue(repoRoot, args) {
return result.stdout.trim();
}
function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, codeChanged = null }) {
function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, environmentInputChanged = null, codeChanged = null, reuseRegistry = null }) {
const reasons = [];
if (envReuse && envChanged) reasons.push("environment-input-changed");
if (envReuse && environmentInputChanged) reasons.push("environment-input-mismatch");
if (envReuse && reuseRegistry && reuseRegistry.status !== "present") reasons.push("reuse-registry-missing");
if (envReuse && envChanged && reasons.length === 0) reasons.push("environment-input-changed");
if (envReuse && codeChanged) reasons.push("code-input-changed");
if (directMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("component-path-changed");
if (sharedMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("shared-runtime-changed");
@@ -780,6 +801,87 @@ function digestFromImageReference(image) {
return match ? match[1] : null;
}
function booleanEnv(value) {
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
}
async function probeRegistryManifest({ image, digest, timeoutMs = 3000 }) {
const request = registryManifestRequest(image, digest);
if (!request) return { status: "invalid-reference", image, digest, reason: "registry-reference-unparseable" };
const result = await registryManifestHttpProbe(request, "HEAD", timeoutMs);
if (result.status === "present" || result.status === "missing") return { ...result, image, digest };
const fallback = await registryManifestHttpProbe(request, "GET", timeoutMs);
return { ...fallback, image, digest };
}
function registryManifestRequest(image, digest) {
const parsed = parseTaggedImage(image);
if (!parsed || !/^sha256:[a-f0-9]{64}$/u.test(digest ?? "")) return null;
const protocol = parsed.host === "127.0.0.1" || parsed.host.startsWith("127.") || parsed.host === "localhost" || parsed.host.includes(":5000")
? "http:"
: "https:";
return {
protocol,
host: parsed.host,
path: `/v2/${parsed.repository}/manifests/${digest}`
};
}
function parseTaggedImage(image) {
const text = String(image ?? "").trim();
const slash = text.indexOf("/");
if (slash <= 0) return null;
const host = text.slice(0, slash);
const rest = text.slice(slash + 1);
const tagSeparator = rest.lastIndexOf(":");
if (!host || tagSeparator <= 0 || rest.includes("@")) return null;
return {
host,
repository: rest.slice(0, tagSeparator)
};
}
function registryManifestHttpProbe(request, method, timeoutMs) {
const client = request.protocol === "http:" ? http : https;
return new Promise((resolve) => {
const req = client.request({
protocol: request.protocol,
host: request.host.includes(":") ? request.host.slice(0, request.host.lastIndexOf(":")) : request.host,
port: request.host.includes(":") ? request.host.slice(request.host.lastIndexOf(":") + 1) : undefined,
method,
path: request.path,
timeout: timeoutMs,
headers: {
Accept: [
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json"
].join(", ")
}
}, (res) => {
res.resume();
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: "present", method, statusCode: res.statusCode });
} else if (res.statusCode === 404) {
resolve({ status: "missing", method, statusCode: res.statusCode });
} else {
resolve({ status: "unknown", method, statusCode: res.statusCode ?? 0, reason: "unexpected-status" });
}
});
});
req.on("timeout", () => {
req.destroy();
resolve({ status: "unknown", method, statusCode: 0, reason: "timeout" });
});
req.on("error", (error) => {
resolve({ status: "unknown", method, statusCode: 0, reason: error.message });
});
req.end();
});
}
function stableHash(value) {
return createHash("sha256").update(stableJson(value)).digest("hex");
}