feat(aws-lambda): push a prebuilt image to ECR without Docker (#6063)

* feat(aws-lambda): push a prebuilt image to ECR without Docker

Adds pushImageToEcr and ensureEcrRepository to the SDK subpath. The push
reads a single-platform OCI image layout, verifies every blob against its
descriptor before any ECR call, uploads only the blobs ECR lacks through
the layer-upload API, and puts the manifest under a tag, returning the
digest-pinned image URI.

* fix(aws-lambda): keep the ECR client out of the SDK bundle

Like the other AWS SDK clients, @aws-sdk/client-ecr is a dependency the consumer
resolves. Bundled into the ESM SDK it hits a dynamic require of node:https and
the packed package fails to import.

* feat(aws-lambda): build the ECR client from a region like the other SDK calls

* build(aws-lambda): add only the ECR client to the lockfile

* fix(aws-lambda): adopt an existing ECR repository only when it carries the caller's tags

* chore(aws-lambda): raise the size budget for pushing an image to ECR

pushImageToEcr ships in dist (unminified chunk plus its .d.ts); no source maps, tests or release-only files are involved. Built dist, npm pack --dry-run, main versus main plus this branch: +2486 B packed, +8846 B unpacked. The merge-group run measured 73369 packed and 266805 unpacked, which are the new budgets (were 70874 and 258166).
This commit is contained in:
Miguel Ángel
2026-10-23 04:35:25 +00:00
committed by GitHub
parent 0255493913
commit 0df088926e
7 changed files with 635 additions and 2 deletions
+3
View File
@@ -29,6 +29,7 @@
"name": "@hyperframes/aws-lambda",
"version": "0.8.169",
"dependencies": {
"@aws-sdk/client-ecr": "^3.1146.0",
"@aws-sdk/client-lambda": "^3.1146.0",
"@aws-sdk/client-s3": "^3.1146.0",
"@aws-sdk/client-sfn": "^3.1146.0",
@@ -444,6 +445,8 @@
"@aws-sdk/checksums": ["@aws-sdk/checksums@3.1001.1", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw=="],
"@aws-sdk/client-ecr": ["@aws-sdk/client-ecr@3.1146.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/credential-provider-node": "^3.972.84", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-6SxQK33Jye0WHjOld95b9S12eHIYtPfpZhuAv9HYCMQYJJyVDhsV8BNWwsNjLEchTc/yjS8ksmzc8MhApDcD3g=="],
"@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1146.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/credential-provider-node": "^3.972.84", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-qCeVHGhkI5aw0Lvexhwuf1H41L2sCm+9C4K+IOUhErY/nx5ioCU0dFyXF4+P4m4Nk23uiTbON7lI7nmlPNncHw=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1146.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1001.1", "@aws-sdk/core": "^3.978.1", "@aws-sdk/credential-provider-node": "^3.972.84", "@aws-sdk/middleware-sdk-s3": "^3.972.77", "@aws-sdk/signature-v4-multi-region": "^3.996.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-WY0YCBzxc4muFfY6UbGpA+oib0nGT/Px2aEPGYX7pp1la2OmDLChnK81mu8H7LdYvwGU1pLAsLIa/qgad0Wd7g=="],
+1
View File
@@ -32,6 +32,7 @@ const sharedOpts = {
format: "esm",
minify: false,
external: [
"@aws-sdk/client-ecr",
"@aws-sdk/client-lambda",
"@aws-sdk/client-s3",
"@aws-sdk/client-sfn",
+1
View File
@@ -72,6 +72,7 @@
"verify:zip-size": "tsx scripts/verify-zip-size.ts"
},
"dependencies": {
"@aws-sdk/client-ecr": "^3.1146.0",
"@aws-sdk/client-lambda": "^3.1146.0",
"@aws-sdk/client-s3": "^3.1146.0",
"@aws-sdk/client-sfn": "^3.1146.0",
+7
View File
@@ -45,5 +45,12 @@ export {
prebuiltVideoImage,
type ReleaseAssetManifest,
} from "./prebuiltHandler.js";
export {
ensureEcrRepository,
type EnsureEcrRepositoryOptions,
type PushedImage,
pushImageToEcr,
type PushImageToEcrOptions,
} from "./pushImageToEcr.js";
export type { SerializableDistributedRenderConfig } from "../events.js";
export type { DistributedFormat } from "../formatExtension.js";
@@ -0,0 +1,367 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ECRClient } from "@aws-sdk/client-ecr";
import { ensureEcrRepository, pushImageToEcr } from "./pushImageToEcr.js";
const OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json";
const OCI_INDEX = "application/vnd.oci.image.index.v1+json";
const REGISTRY = "123456789012.dkr.ecr.us-east-1.amazonaws.com";
const PART_SIZE = 7;
// 21 bytes is exactly three parts; 17 bytes ends with a short part.
const LAYER_A = Buffer.from("layer-a: 21 bytes!!!!");
const LAYER_B = Buffer.from("layer-b: 17 bytes");
interface Descriptor {
mediaType: string;
digest: string;
size: number;
}
function sha256(bytes: Buffer | string): string {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function awsError(name: string): Error {
return Object.assign(new Error(name), { name });
}
/** In-memory ECR that enforces the upload rules real ECR enforces. */
class FakeEcr {
calls: string[] = [];
blobs = new Map<string, Buffer>();
tags = new Map<string, string>();
repositories = new Map<string, string>();
repositoryTags = new Map<string, unknown>();
/** Digests BatchCheckLayerAvailability reports missing even though stored (a concurrent push). */
hiddenFromCheck = new Set<string>();
private uploads = new Map<string, Buffer[]>();
async send(command: unknown): Promise<unknown> {
const name = (command as { constructor: { name: string } }).constructor.name;
const input = (command as { input: unknown }).input;
this.calls.push(name);
const handler = (this as unknown as Record<string, (input: unknown) => unknown>)[name];
if (!handler) throw new Error(`FakeEcr: unexpected command ${name}`);
return handler.call(this, input);
}
CreateRepositoryCommand(input: { repositoryName: string; tags?: unknown }): unknown {
if (this.repositories.has(input.repositoryName)) {
throw awsError("RepositoryAlreadyExistsException");
}
const repositoryUri = `${REGISTRY}/${input.repositoryName}`;
this.repositories.set(input.repositoryName, repositoryUri);
this.repositoryTags.set(input.repositoryName, input.tags);
return { repository: { repositoryUri } };
}
DescribeRepositoriesCommand(input: { repositoryNames: string[] }): unknown {
return {
repositories: input.repositoryNames.map((name) => ({
repositoryArn: `arn:aws:ecr:r:1:repository/${name}`,
repositoryUri: this.repositories.get(name),
})),
};
}
ListTagsForResourceCommand(input: { resourceArn: string }): unknown {
return { tags: this.repositoryTags.get(input.resourceArn.split("/").pop() ?? "") ?? [] };
}
BatchCheckLayerAvailabilityCommand(input: { layerDigests: string[] }): unknown {
if (input.layerDigests.length > 100) throw awsError("InvalidParameterException");
const stored = (d: string) => this.blobs.has(d) && !this.hiddenFromCheck.has(d);
return {
layers: input.layerDigests
.filter(stored)
.map((layerDigest) => ({ layerDigest, layerAvailability: "AVAILABLE" })),
failures: input.layerDigests
.filter((d) => !stored(d))
.map((layerDigest) => ({ layerDigest, failureCode: "MissingLayerDigest" })),
};
}
InitiateLayerUploadCommand(): unknown {
const uploadId = `upload-${this.uploads.size}`;
this.uploads.set(uploadId, []);
return { uploadId, partSize: PART_SIZE };
}
UploadLayerPartCommand(input: {
uploadId: string;
partFirstByte: number;
partLastByte: number;
layerPartBlob: Uint8Array;
}): unknown {
const parts = this.uploads.get(input.uploadId);
if (!parts) throw awsError("UploadNotFoundException");
const received = parts.reduce((sum, part) => sum + part.length, 0);
const size = input.layerPartBlob.length;
const contiguous = input.partFirstByte === received;
const lastByteMatches = input.partLastByte === input.partFirstByte + size - 1;
const previousWasFull = parts.every((part) => part.length === PART_SIZE);
if (!contiguous || !lastByteMatches || size > PART_SIZE || !previousWasFull) {
throw awsError("InvalidLayerPartException");
}
parts.push(Buffer.from(input.layerPartBlob));
return { uploadId: input.uploadId, lastByteReceived: input.partLastByte };
}
CompleteLayerUploadCommand(input: { uploadId: string; layerDigests: string[] }): unknown {
const parts = this.uploads.get(input.uploadId);
if (!parts) throw awsError("UploadNotFoundException");
this.uploads.delete(input.uploadId);
const bytes = Buffer.concat(parts);
const [digest] = input.layerDigests;
if (input.layerDigests.length !== 1 || sha256(bytes) !== digest) {
throw awsError("InvalidLayerException");
}
if (this.blobs.has(digest)) throw awsError("LayerAlreadyExistsException");
this.blobs.set(digest, bytes);
return { layerDigest: digest };
}
PutImageCommand(input: {
imageManifest: string;
imageTag: string;
imageDigest: string;
}): unknown {
if (sha256(input.imageManifest) !== input.imageDigest)
throw awsError("InvalidParameterException");
const manifest = JSON.parse(input.imageManifest) as {
config: Descriptor;
layers: Descriptor[];
};
const referenced = [manifest.config, ...manifest.layers].map((blob) => blob.digest);
if (referenced.some((digest) => !this.blobs.has(digest))) {
throw awsError("LayersNotFoundException");
}
if (this.tags.get(input.imageTag) === input.imageDigest) {
throw awsError("ImageAlreadyExistsException");
}
this.tags.set(input.imageTag, input.imageDigest);
return { image: { imageId: { imageDigest: input.imageDigest, imageTag: input.imageTag } } };
}
}
function asEcr(fake: FakeEcr): ECRClient {
return fake as unknown as ECRClient;
}
let layoutDir: string;
beforeEach(() => {
layoutDir = mkdtempSync(join(tmpdir(), "hf-ecr-layout-"));
});
afterEach(() => {
rmSync(layoutDir, { recursive: true, force: true });
});
function putBlob(mediaType: string, bytes: Buffer): Descriptor {
mkdirSync(join(layoutDir, "blobs", "sha256"), { recursive: true });
const digest = sha256(bytes);
writeFileSync(join(layoutDir, "blobs", "sha256", digest.slice("sha256:".length)), bytes);
return { mediaType, digest, size: bytes.length };
}
function writeIndex(manifests: Descriptor[]): void {
writeFileSync(
join(layoutDir, "index.json"),
JSON.stringify({ schemaVersion: 2, mediaType: OCI_INDEX, manifests }),
);
}
function writeLayout(
layers: Buffer[] = [LAYER_A, LAYER_B],
platform: Record<string, string> = { architecture: "arm64", os: "linux" },
) {
const config = putBlob(
"application/vnd.oci.image.config.v1+json",
Buffer.from(JSON.stringify({ ...platform, rootfs: { type: "layers" } })),
);
const layerDescriptors = layers.map((bytes) =>
putBlob("application/vnd.oci.image.layer.v1.tar+gzip", bytes),
);
const manifest = putBlob(
OCI_MANIFEST,
Buffer.from(
JSON.stringify({
schemaVersion: 2,
mediaType: OCI_MANIFEST,
config,
layers: layerDescriptors,
}),
),
);
writeIndex([manifest]);
return { config, manifest, layers: layerDescriptors };
}
function push(ecr: FakeEcr) {
return pushImageToEcr({
ecr: asEcr(ecr),
layoutDir,
repositoryName: "hyperframes-render",
repositoryUri: `${REGISTRY}/hyperframes-render`,
tag: "v1",
});
}
describe("pushImageToEcr", () => {
it("uploads every blob in partSize parts and puts the manifest under the tag", async () => {
const { config, manifest, layers } = writeLayout();
const ecr = new FakeEcr();
const result = await push(ecr);
expect(result).toEqual({
imageUri: `${REGISTRY}/hyperframes-render@${manifest.digest}`,
digest: manifest.digest,
architecture: "arm64",
os: "linux",
});
expect(ecr.blobs.get(layers[0]!.digest)).toEqual(LAYER_A);
expect(ecr.blobs.get(layers[1]!.digest)).toEqual(LAYER_B);
expect(ecr.blobs.has(config.digest)).toBe(true);
expect(ecr.tags.get("v1")).toBe(manifest.digest);
const parts = ecr.calls.filter((call) => call === "UploadLayerPartCommand").length;
expect(parts).toBe(Math.ceil(config.size / PART_SIZE) + 3 + 3);
expect(ecr.calls[0]).toBe("BatchCheckLayerAvailabilityCommand");
expect(ecr.calls.at(-1)).toBe("PutImageCommand");
});
it("skips blobs ECR already has", async () => {
const { config, layers } = writeLayout();
const ecr = new FakeEcr();
ecr.blobs.set(config.digest, Buffer.from("stored"));
ecr.blobs.set(layers[0]!.digest, LAYER_A);
await push(ecr);
expect(ecr.calls.filter((call) => call === "InitiateLayerUploadCommand")).toHaveLength(1);
expect(ecr.blobs.get(layers[1]!.digest)).toEqual(LAYER_B);
});
it("checks availability in batches of at most 100 digests", async () => {
writeLayout(Array.from({ length: 101 }, (_, i) => Buffer.from(`layer ${i}`)));
const ecr = new FakeEcr();
await push(ecr);
expect(ecr.calls.filter((call) => call === "BatchCheckLayerAvailabilityCommand")).toHaveLength(
2,
);
expect(ecr.blobs.size).toBe(102);
});
it("refuses a tampered blob before making any ECR call", async () => {
const { layers } = writeLayout();
const tampered = Buffer.from(LAYER_B);
tampered[0] = tampered[0]! ^ 1;
writeFileSync(join(layoutDir, "blobs", "sha256", layers[1]!.digest.slice(7)), tampered);
const ecr = new FakeEcr();
await expect(push(ecr)).rejects.toThrow(/does not match its descriptor/);
expect(ecr.calls).toEqual([]);
});
it("refuses an index listing two manifests", async () => {
const { manifest } = writeLayout();
const attestation = putBlob(OCI_MANIFEST, Buffer.from("{}"));
writeIndex([manifest, attestation]);
const ecr = new FakeEcr();
await expect(push(ecr)).rejects.toThrow(/exactly one single-platform image manifest; found 2/);
expect(ecr.calls).toEqual([]);
});
it("refuses a multi-platform image index", async () => {
const { manifest } = writeLayout();
writeIndex([{ ...manifest, mediaType: OCI_INDEX }]);
const ecr = new FakeEcr();
await expect(push(ecr)).rejects.toThrow(`found 1 (${OCI_INDEX})`);
expect(ecr.calls).toEqual([]);
});
it("refuses an image config with no architecture", async () => {
writeLayout([LAYER_A], { os: "linux" });
const ecr = new FakeEcr();
await expect(push(ecr)).rejects.toThrow(/names no architecture\/os/);
expect(ecr.calls).toEqual([]);
});
it("treats LayerAlreadyExists on complete as uploaded", async () => {
const { layers, manifest } = writeLayout();
const ecr = new FakeEcr();
ecr.blobs.set(layers[0]!.digest, LAYER_A);
ecr.hiddenFromCheck.add(layers[0]!.digest);
const result = await push(ecr);
expect(result.digest).toBe(manifest.digest);
expect(ecr.tags.get("v1")).toBe(manifest.digest);
});
it("redeploying the same image uploads nothing and succeeds", async () => {
const { manifest } = writeLayout();
const ecr = new FakeEcr();
await push(ecr);
ecr.calls = [];
const result = await push(ecr);
expect(result.digest).toBe(manifest.digest);
expect(ecr.calls).toEqual(["BatchCheckLayerAvailabilityCommand", "PutImageCommand"]);
});
});
describe("ensureEcrRepository", () => {
it("creates the repository with its tags", async () => {
const ecr = new FakeEcr();
const tags = [{ Key: "app", Value: "hyperframes" }];
const { repositoryUri } = await ensureEcrRepository({
ecr: asEcr(ecr),
repositoryName: "hyperframes-render",
tags,
});
expect(repositoryUri).toBe(`${REGISTRY}/hyperframes-render`);
expect(ecr.calls).toEqual(["CreateRepositoryCommand"]);
expect(ecr.repositoryTags.get("hyperframes-render")).toEqual(tags);
});
it("returns the existing repository's uri", async () => {
const ecr = new FakeEcr();
ecr.repositories.set("hyperframes-render", `${REGISTRY}/hyperframes-render`);
const { repositoryUri } = await ensureEcrRepository({
ecr: asEcr(ecr),
repositoryName: "hyperframes-render",
});
expect(repositoryUri).toBe(`${REGISTRY}/hyperframes-render`);
expect(ecr.calls).toEqual(["CreateRepositoryCommand", "DescribeRepositoriesCommand"]);
});
it("adopts an existing repository only when it carries the tags", async () => {
const ecr = new FakeEcr();
const tags = [{ Key: "hyperframes:stack", Value: "s" }];
await ensureEcrRepository({ ecr: asEcr(ecr), repositoryName: "s-video", tags });
const again = await ensureEcrRepository({ ecr: asEcr(ecr), repositoryName: "s-video", tags });
expect(again.repositoryUri).toBe(`${REGISTRY}/s-video`);
ecr.repositories.set("other-video", `${REGISTRY}/other-video`);
ecr.repositoryTags.set("other-video", [{ Key: "hyperframes:stack", Value: "other" }]);
await expect(
ensureEcrRepository({ ecr: asEcr(ecr), repositoryName: "other-video", tags }),
).rejects.toThrow("other-video already exists without hyperframes:stack=s");
});
});
@@ -0,0 +1,254 @@
/**
* Push a prebuilt single-platform OCI image layout into ECR through the ECR API alone,
* so deploying the container-image function needs neither Docker nor registry HTTP calls.
*/
import { open, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import {
BatchCheckLayerAvailabilityCommand,
CompleteLayerUploadCommand,
CreateRepositoryCommand,
DescribeRepositoriesCommand,
ECRClient,
InitiateLayerUploadCommand,
ListTagsForResourceCommand,
PutImageCommand,
type Repository,
type Tag,
UploadLayerPartCommand,
} from "@aws-sdk/client-ecr";
import { sha256File } from "../s3Transport.js";
const MANIFEST_MEDIA_TYPES = new Set([
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
]);
// ECR's BatchCheckLayerAvailability accepts at most 100 digests per call.
const CHECK_BATCH_SIZE = 100;
interface Descriptor {
mediaType: string;
digest: string;
size: number;
}
/** Options for {@link ensureEcrRepository}. */
export interface EnsureEcrRepositoryOptions {
ecr?: ECRClient;
region?: string;
repositoryName: string;
/** Set on create. An existing repository is adopted only if it already carries every one. */
tags?: Tag[];
}
/** Create the repository, or adopt an existing one that carries `tags`. */
export async function ensureEcrRepository(
opts: EnsureEcrRepositoryOptions,
): Promise<{ repositoryUri: string }> {
const { repositoryName, tags } = opts;
const ecr = opts.ecr ?? new ECRClient({ region: opts.region });
try {
const created = await ecr.send(new CreateRepositoryCommand({ repositoryName, tags }));
return { repositoryUri: repositoryUriOf(created.repository, repositoryName) };
} catch (error) {
if ((error as Error).name !== "RepositoryAlreadyExistsException") throw error;
}
const found = await ecr.send(
new DescribeRepositoriesCommand({ repositoryNames: [repositoryName] }),
);
const repository = found.repositories?.[0];
if (tags?.length) {
const listed = await ecr.send(
new ListTagsForResourceCommand({ resourceArn: repository?.repositoryArn }),
);
const has = new Map((listed.tags ?? []).map((tag) => [tag.Key, tag.Value]));
const missing = tags.filter((tag) => has.get(tag.Key) !== tag.Value);
if (missing.length > 0) {
const wanted = missing.map((tag) => `${tag.Key}=${tag.Value}`).join(", ");
throw new Error(
`[ensureEcrRepository] ${repositoryName} already exists without ${wanted}; refusing to adopt a repository this caller did not create`,
);
}
}
return { repositoryUri: repositoryUriOf(repository, repositoryName) };
}
function repositoryUriOf(repository: Repository | undefined, repositoryName: string): string {
if (!repository?.repositoryUri) {
throw new Error(`[ensureEcrRepository] ECR returned no repositoryUri for ${repositoryName}`);
}
return repository.repositoryUri;
}
/** Options for {@link pushImageToEcr}. */
export interface PushImageToEcrOptions {
ecr?: ECRClient;
region?: string;
/** OCI image layout directory: `index.json` plus `blobs/sha256/<hex>`. */
layoutDir: string;
repositoryName: string;
/** From {@link ensureEcrRepository}. */
repositoryUri: string;
tag: string;
log?: (message: string) => void;
}
/** Result of {@link pushImageToEcr}. */
export interface PushedImage {
/** `<repositoryUri>@sha256:<manifest digest>`, pinned to exactly the pushed bytes. */
imageUri: string;
digest: string;
/** From the image config, so callers can assert the platform they expect. */
architecture: string;
os: string;
}
/** Upload the layout's missing blobs, then put its manifest under `tag`. Safe to re-run. */
export async function pushImageToEcr(opts: PushImageToEcrOptions): Promise<PushedImage> {
const { layoutDir, repositoryName, log = () => {} } = opts;
const ecr = opts.ecr ?? new ECRClient({ region: opts.region });
const manifestDescriptor = await readManifestDescriptor(layoutDir);
await verifyBlob(layoutDir, manifestDescriptor);
const manifestText = await readFile(blobPath(layoutDir, manifestDescriptor.digest), "utf-8");
const manifest = JSON.parse(manifestText) as { config?: Descriptor; layers?: Descriptor[] };
if (!manifest.config || !Array.isArray(manifest.layers)) {
throw new Error(
`[pushImageToEcr] manifest ${manifestDescriptor.digest} has no config or layers`,
);
}
const blobs = [manifest.config, ...manifest.layers];
for (const blob of blobs) await verifyBlob(layoutDir, blob);
const config = JSON.parse(
await readFile(blobPath(layoutDir, manifest.config.digest), "utf-8"),
) as { architecture?: string; os?: string };
if (!config.architecture || !config.os) {
throw new Error(
`[pushImageToEcr] image config ${manifest.config.digest} names no architecture/os; ` +
"build the image for one platform",
);
}
const missing = await unavailableDigests(ecr, repositoryName, blobs);
// ponytail: sequential uploads; upload time on big layers is the ceiling, bounded parallel layers is the upgrade.
for (const blob of blobs) {
if (!missing.delete(blob.digest)) continue;
log(`uploading ${blob.digest} (${blob.size} bytes)`);
await uploadBlob(ecr, repositoryName, blobPath(layoutDir, blob.digest), blob);
}
try {
await ecr.send(
new PutImageCommand({
repositoryName,
imageManifest: manifestText,
imageManifestMediaType: manifestDescriptor.mediaType,
imageTag: opts.tag,
imageDigest: manifestDescriptor.digest,
}),
);
} catch (error) {
if ((error as Error).name !== "ImageAlreadyExistsException") throw error;
}
return {
imageUri: `${opts.repositoryUri}@${manifestDescriptor.digest}`,
digest: manifestDescriptor.digest,
architecture: config.architecture,
os: config.os,
};
}
async function readManifestDescriptor(layoutDir: string): Promise<Descriptor> {
const index = JSON.parse(await readFile(join(layoutDir, "index.json"), "utf-8")) as {
manifests?: Descriptor[];
};
const manifests = index.manifests ?? [];
const [only] = manifests;
if (manifests.length !== 1 || !only || !MANIFEST_MEDIA_TYPES.has(only.mediaType)) {
const found = manifests.map((m) => m.mediaType).join(", ") || "none";
throw new Error(
`[pushImageToEcr] ${layoutDir}/index.json must list exactly one single-platform image ` +
`manifest; found ${manifests.length} (${found}). Build one platform with ` +
"--provenance=false --sbom=false.",
);
}
return only;
}
function blobPath(layoutDir: string, digest: string): string {
const hex = /^sha256:([a-f0-9]{64})$/.exec(digest)?.[1];
if (!hex) throw new Error(`[pushImageToEcr] unsupported blob digest ${JSON.stringify(digest)}`);
return join(layoutDir, "blobs", "sha256", hex);
}
async function verifyBlob(layoutDir: string, blob: Descriptor): Promise<void> {
const path = blobPath(layoutDir, blob.digest);
const size = (await stat(path)).size;
if (size !== blob.size || `sha256:${await sha256File(path)}` !== blob.digest) {
throw new Error(
`[pushImageToEcr] blob ${blob.digest} (${size} bytes, expected ${blob.size}) does not ` +
"match its descriptor; the image layout is corrupt",
);
}
}
async function unavailableDigests(
ecr: ECRClient,
repositoryName: string,
blobs: Descriptor[],
): Promise<Set<string>> {
const missing = new Set(blobs.map((blob) => blob.digest));
const digests = [...missing];
for (let i = 0; i < digests.length; i += CHECK_BATCH_SIZE) {
const { layers = [] } = await ecr.send(
new BatchCheckLayerAvailabilityCommand({
repositoryName,
layerDigests: digests.slice(i, i + CHECK_BATCH_SIZE),
}),
);
for (const layer of layers) {
if (layer.layerAvailability === "AVAILABLE" && layer.layerDigest) {
missing.delete(layer.layerDigest);
}
}
}
return missing;
}
async function uploadBlob(
ecr: ECRClient,
repositoryName: string,
path: string,
blob: Descriptor,
): Promise<void> {
const { uploadId, partSize } = await ecr.send(new InitiateLayerUploadCommand({ repositoryName }));
if (!uploadId || !partSize) {
throw new Error(`[pushImageToEcr] InitiateLayerUpload returned no uploadId/partSize`);
}
const file = await open(path);
try {
for (let first = 0; first < blob.size; first += partSize) {
const part = Buffer.alloc(Math.min(partSize, blob.size - first));
const { bytesRead } = await file.read(part, 0, part.length, first);
if (bytesRead !== part.length) throw new Error(`[pushImageToEcr] ${path} changed on disk`);
await ecr.send(
new UploadLayerPartCommand({
repositoryName,
uploadId,
partFirstByte: first,
partLastByte: first + part.length - 1,
layerPartBlob: part,
}),
);
}
} finally {
await file.close();
}
try {
await ecr.send(
new CompleteLayerUploadCommand({ repositoryName, uploadId, layerDigests: [blob.digest] }),
);
} catch (error) {
if ((error as Error).name !== "LayerAlreadyExistsException") throw error;
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"@hyperframes/aws-lambda": {
"packed": 70874,
"unpacked": 258166
"packed": 73369,
"unpacked": 266805
},
"@hyperframes/cli": {
"packed": 7902966,