fix: reuse env catalog across hash metadata drift
This commit is contained in:
@@ -152,7 +152,7 @@ test("v02 planner rebuilds env image when an env reuse catalog digest is missing
|
||||
assert.deepEqual(cloudApi.reason, ["environment-input-changed"]);
|
||||
});
|
||||
|
||||
test("v03 planner rebuilds env image when catalog env input hash is stale", async () => {
|
||||
test("v03 planner rebuilds env image when catalog env input hash is stale and source commit is unavailable", async () => {
|
||||
const repo = await createFixtureRepo();
|
||||
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
const initialPlan = await createCiPlan({
|
||||
@@ -163,7 +163,7 @@ test("v03 planner rebuilds env image when catalog env input hash is stale", asyn
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v03.json",
|
||||
services: ["hwlab-cloud-api"]
|
||||
});
|
||||
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
|
||||
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: "0".repeat(40) }));
|
||||
catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input";
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
|
||||
@@ -184,6 +184,64 @@ test("v03 planner rebuilds env image when catalog env input hash is stale", asyn
|
||||
assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]);
|
||||
});
|
||||
|
||||
test("v03 planner reuses catalog env image when hash metadata drifts but env source tree is unchanged", async () => {
|
||||
const repo = await createFixtureRepo();
|
||||
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
const initialPlan = await createCiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v03",
|
||||
baseRef: "HEAD",
|
||||
targetRef: initialSha,
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v03.json",
|
||||
services: ["hwlab-cloud-api"]
|
||||
});
|
||||
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
|
||||
const catalogCloudApi = catalog.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
catalogCloudApi.environmentInputHash = "stale-environment-input";
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
|
||||
await git(repo, ["commit", "-m", "seed drifted v03 env catalog"]);
|
||||
|
||||
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n");
|
||||
await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]);
|
||||
await git(repo, ["commit", "-m", "change cloud-api code only"]);
|
||||
|
||||
const requests = [];
|
||||
const plan = await createCiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v03",
|
||||
baseRef: "HEAD~1",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v03.json",
|
||||
services: ["hwlab-cloud-api"],
|
||||
verifyReuseRegistry: true,
|
||||
reuseRegistryProbe: async (request) => {
|
||||
requests.push(request);
|
||||
if (request.image === catalogCloudApi.environmentImage && request.digest === catalogCloudApi.environmentDigest) {
|
||||
return { status: "present", method: "HEAD", statusCode: 200, digest: catalogCloudApi.environmentDigest };
|
||||
}
|
||||
return { status: "missing", method: "HEAD", statusCode: 404 };
|
||||
}
|
||||
});
|
||||
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
assert.equal(cloudApi.environmentInputChanged, true);
|
||||
assert.equal(cloudApi.effectiveEnvironmentInputChanged, false);
|
||||
assert.equal(cloudApi.environmentMetadataHashDrift, true);
|
||||
assert.equal(cloudApi.environmentSourceUnchanged, true);
|
||||
assert.equal(cloudApi.envChanged, false);
|
||||
assert.equal(cloudApi.codeChanged, true);
|
||||
assert.equal(cloudApi.affected, true);
|
||||
assert.equal(cloudApi.buildRequired, false);
|
||||
assert.equal(cloudApi.environmentImage, catalogCloudApi.environmentImage);
|
||||
assert.equal(cloudApi.environmentDigest, catalogCloudApi.environmentDigest);
|
||||
assert.equal(cloudApi.reuseRegistry.reuseSource, "catalog-metadata-hash-drift");
|
||||
assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
|
||||
assert.deepEqual(plan.buildServices, []);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.match(requests[0].image, /hwlab-cloud-api-env:env-/u);
|
||||
assert.equal(requests[1].image, catalogCloudApi.environmentImage);
|
||||
});
|
||||
|
||||
test("v03 planner reuses current env hash tag when registry already has it", async () => {
|
||||
const repo = await createFixtureRepo();
|
||||
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
|
||||
@@ -130,24 +130,60 @@ export async function createCiPlan(options = {}) {
|
||||
const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, {
|
||||
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
|
||||
}) : null;
|
||||
const environmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash) : null;
|
||||
const currentEnvironmentImage = envReuse ? envReuseImageRef(registryPrefix, model.serviceId, environmentInputHash) : null;
|
||||
const catalogEnvironmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
|
||||
const catalogEnvironmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
|
||||
const catalogEnvironmentSourceCommitId = envReuse ? text(catalogRecord?.sourceCommitId) : "";
|
||||
const catalogEnvironmentInputHashAtSource = envReuse && catalogEnvironmentSourceCommitId
|
||||
? await hashEnvReuseInputsIfCommitExists(repoRoot, catalogEnvironmentSourceCommitId, envInputPaths, serviceEnvReuseRecipe, {
|
||||
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
|
||||
semanticPackageJson: true
|
||||
})
|
||||
: null;
|
||||
const environmentSourceUnchanged = envReuse && Boolean(catalogEnvironmentInputHashAtSource) && catalogEnvironmentInputHashAtSource === environmentInputHash;
|
||||
let environmentImage = currentEnvironmentImage;
|
||||
let environmentDigest = envReuse ? environmentDigestFromCatalogForHash(catalogRecord, environmentInputHash) : null;
|
||||
const environmentInputChanged = envReuse ? catalogRecord?.environmentInputHash !== environmentInputHash : null;
|
||||
const reuseRegistry = envReuse && reuseRegistryProbe && environmentImage
|
||||
const currentReuseRegistry = envReuse && reuseRegistryProbe && currentEnvironmentImage
|
||||
? await reuseRegistryProbe({
|
||||
serviceId: model.serviceId,
|
||||
image: environmentImage,
|
||||
image: currentEnvironmentImage,
|
||||
digest: environmentDigest,
|
||||
registryPrefix,
|
||||
timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000
|
||||
})
|
||||
: null;
|
||||
let reuseRegistry = currentReuseRegistry;
|
||||
let environmentMetadataHashDrift = false;
|
||||
const catalogEnvironmentReusable = envReuse
|
||||
&& environmentInputChanged === true
|
||||
&& environmentSourceUnchanged
|
||||
&& Boolean(catalogEnvironmentImage)
|
||||
&& /^sha256:[a-f0-9]{64}$/u.test(catalogEnvironmentDigest ?? "");
|
||||
if (catalogEnvironmentReusable && currentReuseRegistry?.status !== "present") {
|
||||
const catalogReuseRegistry = reuseRegistryProbe && catalogEnvironmentImage
|
||||
? await reuseRegistryProbe({
|
||||
serviceId: model.serviceId,
|
||||
image: catalogEnvironmentImage,
|
||||
digest: catalogEnvironmentDigest,
|
||||
registryPrefix,
|
||||
timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000
|
||||
})
|
||||
: null;
|
||||
if (!reuseRegistryProbe || catalogReuseRegistry?.status === "present") {
|
||||
environmentImage = catalogEnvironmentImage;
|
||||
environmentDigest = catalogReuseRegistry?.digest ?? catalogEnvironmentDigest;
|
||||
reuseRegistry = catalogReuseRegistry ? { ...catalogReuseRegistry, reuseSource: "catalog-metadata-hash-drift" } : null;
|
||||
environmentMetadataHashDrift = true;
|
||||
}
|
||||
}
|
||||
if (reuseRegistry?.status === "present" && /^sha256:[a-f0-9]{64}$/u.test(reuseRegistry.digest ?? "")) {
|
||||
environmentDigest = reuseRegistry.digest;
|
||||
}
|
||||
const reuseRegistryUnavailable = Boolean(reuseRegistry && reuseRegistry.status !== "present");
|
||||
const digestReady = /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "");
|
||||
const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !environmentInputChanged && digestReady);
|
||||
const effectiveEnvironmentInputChanged = environmentInputChanged === true && !environmentMetadataHashDrift;
|
||||
const environmentReady = envReuse && Boolean(environmentImage) && (reuseRegistryProbe ? reuseRegistry?.status === "present" && digestReady : !effectiveEnvironmentInputChanged && digestReady);
|
||||
const envChanged = envReuse ? Boolean(relevantEnvMatches.length > 0 || !environmentReady || reuseRegistryUnavailable) : null;
|
||||
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null;
|
||||
const componentInputPaths = uniqueSorted([
|
||||
@@ -230,6 +266,10 @@ export async function createCiPlan(options = {}) {
|
||||
envChanged,
|
||||
runtimeConfigChanged,
|
||||
environmentInputChanged,
|
||||
effectiveEnvironmentInputChanged,
|
||||
environmentMetadataHashDrift,
|
||||
catalogEnvironmentInputHashAtSource,
|
||||
environmentSourceUnchanged,
|
||||
codeChanged,
|
||||
reuseRegistry,
|
||||
environmentInputHash,
|
||||
@@ -242,7 +282,7 @@ export async function createCiPlan(options = {}) {
|
||||
envChangedPaths: relevantEnvMatches,
|
||||
codeChangedPaths: relevantCodeMatches,
|
||||
reason: affected
|
||||
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged, codeChanged, reuseRegistry })
|
||||
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, runtimeConfigChanged, environmentInputChanged: effectiveEnvironmentInputChanged, codeChanged, reuseRegistry })
|
||||
: reasonForUnchanged(globalChange),
|
||||
reuse: catalogReuse
|
||||
});
|
||||
@@ -721,6 +761,15 @@ async function hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options =
|
||||
});
|
||||
}
|
||||
|
||||
async function hashEnvReuseInputsIfCommitExists(repoRoot, targetRef, paths, recipe, options = {}) {
|
||||
try {
|
||||
await gitValue(repoRoot, ["rev-parse", "--verify", `${targetRef}^{commit}`]);
|
||||
return await hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function envReuseIdentityRecipe(recipe) {
|
||||
const identity = JSON.parse(JSON.stringify(recipe ?? {}));
|
||||
if (identity.downloadStack && typeof identity.downloadStack === "object") {
|
||||
|
||||
Reference in New Issue
Block a user