bc605eedaf
A deployment could be marked deployed and promoted to current without its image ever landing in the registry. Finalize trusted the CLI: the v1 path never pushed or checked, and the v2/v3 path skips its own push when the CLI sends `skipPushToRegistry` - which the local-build path always does. In the happy path the CLI pushes the image itself, so this stayed latent. But any deviation - `--no-push`/`--load`, a push that lands in a different registry, or an old CLI - promoted a version whose image can't be pulled, so every run failed at pull time while the deploy itself reported success. This adds a registry existence check after push and before finalize. If the image isn't there, the deploy fails loudly instead of promoting a version that can't start. The check is ECR-only (a no-op for other registries, so self-hosted setups are unaffected) and uses `BatchGetImage`, which the deploy role already allows. It fails open on an ambiguous registry error so the check can't itself turn into a deploy outage. The image reference is the platform-generated value and the lookup is bound to the configured registry host; the CLI-supplied digest is validated before use. Can be turned off with `DEPLOY_IMAGE_VERIFICATION_ENABLED=0` for setups that push images out of band (e.g. an air-gapped registry the platform can't reach). refs TRI-11243
178 lines
5.8 KiB
TypeScript
178 lines
5.8 KiB
TypeScript
import { RepositoryNotFoundException } from "@aws-sdk/client-ecr";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
ecrImageExists,
|
|
interpretBatchGetImageResponse,
|
|
parseEcrImageReference,
|
|
} from "~/v3/services/verifyDeploymentImage.server";
|
|
import { type RegistryConfig } from "~/v3/registryConfig.server";
|
|
|
|
const ECR_HOST = "123456789012.dkr.ecr.us-east-1.amazonaws.com";
|
|
const ecrConfig: RegistryConfig = { host: ECR_HOST, namespace: "deployments-test" };
|
|
|
|
describe("parseEcrImageReference", () => {
|
|
it("splits repository and tag for a ref under the configured host", () => {
|
|
const ref = `${ECR_HOST}/deployments-test/proj_abc:20240101.1.prod.a1b2c3d4`;
|
|
expect(parseEcrImageReference(ref, ECR_HOST)).toEqual({
|
|
repositoryName: "deployments-test/proj_abc",
|
|
tag: "20240101.1.prod.a1b2c3d4",
|
|
});
|
|
});
|
|
|
|
it("drops a trailing @sha256 digest", () => {
|
|
const ref = `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4@sha256:${"a".repeat(64)}`;
|
|
expect(parseEcrImageReference(ref, ECR_HOST)).toEqual({
|
|
repositoryName: "deployments-test/proj_abc",
|
|
tag: "v1.prod.a1b2c3d4",
|
|
});
|
|
});
|
|
|
|
it("returns null when the ref is not under the configured host (trust boundary)", () => {
|
|
const ref = "evil.example.com/whatever/proj_abc:v1";
|
|
expect(parseEcrImageReference(ref, ECR_HOST)).toBeNull();
|
|
});
|
|
|
|
it("returns null when there is no tag", () => {
|
|
expect(parseEcrImageReference(`${ECR_HOST}/deployments-test/proj_abc`, ECR_HOST)).toBeNull();
|
|
});
|
|
|
|
it("returns null when the tag segment contains a slash", () => {
|
|
// a stray colon earlier in the path must not be treated as the tag separator
|
|
expect(parseEcrImageReference(`${ECR_HOST}/ns:weird/proj_abc`, ECR_HOST)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("interpretBatchGetImageResponse", () => {
|
|
it("returns found when an image is present", () => {
|
|
expect(interpretBatchGetImageResponse({ images: [{}] } as any)).toBe("found");
|
|
});
|
|
|
|
it("returns missing on an ImageNotFound failure", () => {
|
|
expect(
|
|
interpretBatchGetImageResponse({ failures: [{ failureCode: "ImageNotFound" }] } as any)
|
|
).toBe("missing");
|
|
});
|
|
|
|
it("returns unknown when there is neither an image nor a not-found failure", () => {
|
|
expect(interpretBatchGetImageResponse({ failures: [{ failureCode: "Other" }] } as any)).toBe(
|
|
"unknown"
|
|
);
|
|
expect(interpretBatchGetImageResponse({} as any)).toBe("unknown");
|
|
});
|
|
});
|
|
|
|
describe("ecrImageExists", () => {
|
|
it("returns unknown for a non-ECR registry without calling the registry", async () => {
|
|
let called = false;
|
|
const result = await ecrImageExists(
|
|
{
|
|
imageReference: "registry.digitalocean.com/trigger-deployments/proj_abc:v1",
|
|
registryConfig: { host: "registry.digitalocean.com", namespace: "trigger-deployments" },
|
|
},
|
|
async () => {
|
|
called = true;
|
|
return {} as any;
|
|
}
|
|
);
|
|
expect(result).toBe("unknown");
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
it("returns unknown for an unparseable ECR ref without calling the registry", async () => {
|
|
let called = false;
|
|
const result = await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc`,
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async () => {
|
|
called = true;
|
|
return {} as any;
|
|
}
|
|
);
|
|
expect(result).toBe("unknown");
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
it("returns found when the image exists", async () => {
|
|
const result = await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async () => ({ images: [{}] }) as any
|
|
);
|
|
expect(result).toBe("found");
|
|
});
|
|
|
|
it("returns missing when the registry reports ImageNotFound", async () => {
|
|
const result = await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async () => ({ failures: [{ failureCode: "ImageNotFound" }] }) as any
|
|
);
|
|
expect(result).toBe("missing");
|
|
});
|
|
|
|
it("returns unknown when the registry call throws an ambiguous error", async () => {
|
|
const result = await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async () => {
|
|
throw new Error("AccessDenied");
|
|
}
|
|
);
|
|
expect(result).toBe("unknown");
|
|
});
|
|
|
|
it("returns missing when the repository does not exist", async () => {
|
|
const result = await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async () => {
|
|
throw new RepositoryNotFoundException({ message: "not found", $metadata: {} });
|
|
}
|
|
);
|
|
expect(result).toBe("missing");
|
|
});
|
|
|
|
it("queries by digest when a valid digest is supplied", async () => {
|
|
const digest = `sha256:${"b".repeat(64)}`;
|
|
let seen: any;
|
|
await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
|
|
imageDigest: digest,
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async (input) => {
|
|
seen = input;
|
|
return { images: [{}] } as any;
|
|
}
|
|
);
|
|
expect(seen.imageIds).toEqual([{ imageDigest: digest }]);
|
|
});
|
|
|
|
it("falls back to the tag when the supplied digest is malformed", async () => {
|
|
let seen: any;
|
|
await ecrImageExists(
|
|
{
|
|
imageReference: `${ECR_HOST}/deployments-test/proj_abc:v1.prod.a1b2c3d4`,
|
|
imageDigest: "not-a-digest",
|
|
registryConfig: ecrConfig,
|
|
},
|
|
async (input) => {
|
|
seen = input;
|
|
return { images: [{}] } as any;
|
|
}
|
|
);
|
|
expect(seen.imageIds).toEqual([{ imageTag: "v1.prod.a1b2c3d4" }]);
|
|
});
|
|
});
|