@specific.dev/spectest 0.92.1 → 0.93.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/components/email.js +14 -39
- package/dist/components/index.d.ts +1 -1
- package/dist/components/k3s.d.ts +26 -4
- package/dist/components/k3s.js +105 -61
- package/dist/components/s3.js +2 -1
- package/dist/components/supabase.js +2 -1
- package/dist/daemon.js +3 -0
- package/dist/harness/built-image.d.ts +16 -0
- package/dist/harness/built-image.js +57 -0
- package/dist/harness/k3s-image-loader.js +2 -53
- package/dist/harness/registry-seed.d.ts +12 -0
- package/dist/harness/registry-seed.js +61 -0
- package/dist/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/email.ts +12 -20
- package/src/components/index.ts +1 -0
- package/src/components/internal-fetch.test.ts +109 -0
- package/src/components/k3s-registry.test.ts +50 -0
- package/src/components/k3s.ts +114 -52
- package/src/components/s3.ts +2 -1
- package/src/components/supabase.ts +2 -1
- package/src/daemon.ts +3 -0
- package/src/harness/built-image.ts +53 -0
- package/src/harness/k3s-image-loader.ts +2 -47
- package/src/harness/registry-seed.test.ts +60 -0
- package/src/harness/registry-seed.ts +61 -0
- package/src/index.ts +2 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Read build results without reserializing manifests or exporting layers. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { canonicalImageReference } from "./component-images.js";
|
|
6
|
+
|
|
7
|
+
export interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
8
|
+
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
9
|
+
const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
10
|
+
const descriptor = (bytes: Uint8Array, mediaType: string): Descriptor => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
13
|
+
|
|
14
|
+
async function hostContent(id: string): Promise<Buffer> {
|
|
15
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid content digest ${id}`);
|
|
16
|
+
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
17
|
+
if (digest(result.stdout) !== id) throw new Error(`corrupt image metadata ${id}`);
|
|
18
|
+
return result.stdout;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
22
|
+
export async function hostImage(source: string) {
|
|
23
|
+
const name = canonicalImageReference(source);
|
|
24
|
+
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
25
|
+
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
26
|
+
if (!found) throw new Error(`built image ${name} is missing from the VM container store`);
|
|
27
|
+
const metadata = new Map<string, Buffer>();
|
|
28
|
+
const allowed = new Map<string, Descriptor>();
|
|
29
|
+
let configDigest: string | undefined;
|
|
30
|
+
async function visit(id: string, selected: boolean, depth = 0): Promise<Descriptor> {
|
|
31
|
+
if (depth > 4) throw new Error(`built image ${name} has a recursive manifest index`);
|
|
32
|
+
const bytes = await hostContent(id);
|
|
33
|
+
const obj = JSON.parse(bytes.toString());
|
|
34
|
+
const desc = descriptor(bytes, obj.mediaType);
|
|
35
|
+
metadata.set(id, bytes);
|
|
36
|
+
allowed.set(id, desc);
|
|
37
|
+
if (obj.config && Array.isArray(obj.layers)) {
|
|
38
|
+
const manifest = obj as Manifest;
|
|
39
|
+
for (const blob of [manifest.config, ...manifest.layers]) allowed.set(blob.digest, blob);
|
|
40
|
+
if (selected) configDigest = manifest.config.digest;
|
|
41
|
+
} else if (Array.isArray(obj.manifests)) {
|
|
42
|
+
for (const entry of obj.manifests as Descriptor[]) {
|
|
43
|
+
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
44
|
+
await visit(entry.digest, selected && matches, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
} else throw new Error(`built image ${name} has invalid image metadata`);
|
|
47
|
+
return desc;
|
|
48
|
+
}
|
|
49
|
+
const target = await visit(found, true);
|
|
50
|
+
if (!configDigest) throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
51
|
+
return { target, metadata, allowed, configDigest };
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -1,62 +1,17 @@
|
|
|
1
1
|
/** A short-lived, read-only OCI endpoint over the host containerd content store.
|
|
2
2
|
* k3s fetches only missing blobs and unpacks only missing snapshots. No Docker
|
|
3
3
|
* archive, staging registry, or second persistent copy of application blobs. */
|
|
4
|
-
import {
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
7
|
import type { SpectestContext } from "../index.js";
|
|
9
8
|
import { canonicalImageReference, type PreparedComponentImage } from "./component-images.js";
|
|
10
9
|
import { waitForK3sRuntime } from "./k3s-prepull.js";
|
|
10
|
+
import { hostImage } from "./built-image.js";
|
|
11
11
|
import { waitForAll } from "./concurrency.js";
|
|
12
12
|
|
|
13
|
-
interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
14
|
-
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
15
|
-
const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
16
|
-
const descriptor = (bytes: Uint8Array, mediaType: string): Descriptor => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
17
|
-
const run = promisify(execFile);
|
|
18
13
|
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
19
14
|
|
|
20
|
-
async function hostContent(id: string): Promise<Buffer> {
|
|
21
|
-
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid content digest ${id}`);
|
|
22
|
-
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
23
|
-
if (digest(result.stdout) !== id) throw new Error(`corrupt image metadata ${id}`);
|
|
24
|
-
return result.stdout;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
28
|
-
async function hostImage(source: string) {
|
|
29
|
-
const name = canonicalImageReference(source);
|
|
30
|
-
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
31
|
-
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
32
|
-
if (!found) throw new Error(`built image ${name} is missing from the VM container store`);
|
|
33
|
-
const metadata = new Map<string, Buffer>();
|
|
34
|
-
const allowed = new Map<string, Descriptor>();
|
|
35
|
-
let configDigest: string | undefined;
|
|
36
|
-
async function visit(id: string, selected: boolean, depth = 0): Promise<Descriptor> {
|
|
37
|
-
if (depth > 4) throw new Error(`built image ${name} has a recursive manifest index`);
|
|
38
|
-
const bytes = await hostContent(id);
|
|
39
|
-
const obj = JSON.parse(bytes.toString());
|
|
40
|
-
const desc = descriptor(bytes, obj.mediaType);
|
|
41
|
-
metadata.set(id, bytes);
|
|
42
|
-
allowed.set(id, desc);
|
|
43
|
-
if (obj.config && Array.isArray(obj.layers)) {
|
|
44
|
-
const manifest = obj as Manifest;
|
|
45
|
-
for (const blob of [manifest.config, ...manifest.layers]) allowed.set(blob.digest, blob);
|
|
46
|
-
if (selected) configDigest = manifest.config.digest;
|
|
47
|
-
} else if (Array.isArray(obj.manifests)) {
|
|
48
|
-
for (const entry of obj.manifests as Descriptor[]) {
|
|
49
|
-
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
50
|
-
await visit(entry.digest, selected && matches, depth + 1);
|
|
51
|
-
}
|
|
52
|
-
} else throw new Error(`built image ${name} has invalid image metadata`);
|
|
53
|
-
return desc;
|
|
54
|
-
}
|
|
55
|
-
const target = await visit(found, true);
|
|
56
|
-
if (!configDigest) throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
57
|
-
return { target, metadata, allowed, configDigest };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
15
|
export async function loadK3sBuildImages(
|
|
61
16
|
service: string, images: readonly PreparedComponentImage[],
|
|
62
17
|
gateway: string, exec: SpectestContext["exec"],
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { seedRegistryImage } from "./registry-seed";
|
|
7
|
+
import type { Descriptor } from "./built-image";
|
|
8
|
+
|
|
9
|
+
const roots: string[] = [];
|
|
10
|
+
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
|
11
|
+
|
|
12
|
+
async function fixture() {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), "registry-seed-")); roots.push(root);
|
|
14
|
+
const content = `${root}/content`; await mkdir(content);
|
|
15
|
+
const metadata = new Map<string, Buffer>(), allowed = new Map<string, Descriptor>();
|
|
16
|
+
async function blob(value: string, mediaType: string, manifest = false) {
|
|
17
|
+
const bytes = Buffer.from(value);
|
|
18
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
19
|
+
const descriptor = { digest, size: bytes.length, mediaType };
|
|
20
|
+
await writeFile(`${content}/${digest.slice(7)}`, bytes);
|
|
21
|
+
allowed.set(digest, descriptor);
|
|
22
|
+
if (manifest) metadata.set(digest, bytes);
|
|
23
|
+
return descriptor;
|
|
24
|
+
}
|
|
25
|
+
const config = await blob('{"rootfs":{"type":"layers","diff_ids":[]}}', "application/vnd.oci.image.config.v1+json");
|
|
26
|
+
const layer = await blob("layer bytes", "application/vnd.oci.image.layer.v1.tar");
|
|
27
|
+
const manifestType = "application/vnd.oci.image.manifest.v1+json";
|
|
28
|
+
const manifest = await blob(JSON.stringify({ schemaVersion: 2, mediaType: manifestType, config, layers: [layer] }), manifestType, true);
|
|
29
|
+
const indexType = "application/vnd.oci.image.index.v1+json";
|
|
30
|
+
const target = await blob(JSON.stringify({ schemaVersion: 2, mediaType: indexType, manifests: [manifest] }), indexType, true);
|
|
31
|
+
return { root, content, layer, image: { target, metadata, allowed, configDigest: config.digest } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("seeding preserves index/manifest bytes and shares blobs while repository state stays separate", async () => {
|
|
35
|
+
const { root, content, image } = await fixture();
|
|
36
|
+
for (const name of ["a", "b"]) {
|
|
37
|
+
await seedRegistryImage(`${root}/${name}`, content, "registry.test/team/code:latest", image, content);
|
|
38
|
+
}
|
|
39
|
+
const base = "/docker/registry/v2";
|
|
40
|
+
for (const id of image.allowed.keys()) {
|
|
41
|
+
const hex = id.slice(7), path = `${base}/blobs/sha256/${hex.slice(0, 2)}/${hex}/data`;
|
|
42
|
+
expect(await readFile(`${root}/a${path}`)).toEqual(await readFile(`${content}/${hex}`));
|
|
43
|
+
expect((await stat(`${root}/a${path}`)).ino).toBe((await stat(`${root}/b${path}`)).ino);
|
|
44
|
+
}
|
|
45
|
+
const tag = `${base}/repositories/team/code/_manifests/tags/latest/current/link`;
|
|
46
|
+
expect(await readFile(`${root}/a${tag}`, "utf8")).toBe(image.target.digest);
|
|
47
|
+
await writeFile(`${root}/a${tag}`, `sha256:${"f".repeat(64)}`);
|
|
48
|
+
expect(await readFile(`${root}/b${tag}`, "utf8")).toBe(image.target.digest);
|
|
49
|
+
// A new environment seeded against the same cached blobs has only its declaration.
|
|
50
|
+
await seedRegistryImage(`${root}/fresh`, content, "registry.test/team/code:latest", image, content);
|
|
51
|
+
expect(await readdir(`${root}/fresh${base}/repositories/team/code/_manifests/tags`)).toEqual(["latest"]);
|
|
52
|
+
expect(await readFile(`${root}/fresh${tag}`, "utf8")).toBe(image.target.digest);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an incomplete cached build cannot publish a usable tag", async () => {
|
|
56
|
+
const { root, content, image, layer } = await fixture();
|
|
57
|
+
await writeFile(`${content}/${layer.digest.slice(7)}`, "truncated");
|
|
58
|
+
await expect(seedRegistryImage(`${root}/data`, content, "registry.test/code:latest", image, content)).rejects.toThrow("incomplete");
|
|
59
|
+
expect(await Bun.file(`${root}/data/docker/registry/v2/repositories/code/_manifests/tags/latest/current/link`).exists()).toBe(false);
|
|
60
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Distribution v2 publication. Blob data stays in containerd, mounted
|
|
2
|
+
* read-only in the registry. Repository metadata and uploads MUST live on
|
|
3
|
+
* the environment rootfs, outside persistent image/build caches. */
|
|
4
|
+
import { mkdir, stat, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { hostImage } from "./built-image.js";
|
|
7
|
+
import { canonicalImageReference, type PreparedComponentImage } from "./component-images.js";
|
|
8
|
+
|
|
9
|
+
export const REGISTRY_DATA = "/var/lib/spectest-registry";
|
|
10
|
+
export const REGISTRY_CONTENT = "/spectest-registry-content";
|
|
11
|
+
export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
|
|
12
|
+
export const REGISTRY_IMAGE = "registry:2.8.3";
|
|
13
|
+
export const registryDirectory = (service: string) => `/var/lib/spectest-registries/${service}`;
|
|
14
|
+
|
|
15
|
+
/** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
|
|
16
|
+
* before starting the registry so it cannot cache partial publication.
|
|
17
|
+
* The prepared build's containerd image record retains the referenced blobs. */
|
|
18
|
+
export async function seedRegistryImage(
|
|
19
|
+
directory: string, contentDirectory: string, reference: string,
|
|
20
|
+
image: Awaited<ReturnType<typeof hostImage>>,
|
|
21
|
+
mountedContent = REGISTRY_CONTENT,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const canonical = canonicalImageReference(reference);
|
|
24
|
+
if (canonical.includes("@")) throw new Error("registry publication must name a tag");
|
|
25
|
+
const repository = canonical.slice(canonical.indexOf("/") + 1, canonical.lastIndexOf(":"));
|
|
26
|
+
const tag = canonical.slice(canonical.lastIndexOf(":") + 1);
|
|
27
|
+
const root = `${directory}/docker/registry/v2`;
|
|
28
|
+
const repo = `${root}/repositories/${repository}`;
|
|
29
|
+
const link = async (path: string, value: string) => {
|
|
30
|
+
await mkdir(dirname(path), { recursive: true });
|
|
31
|
+
await writeFile(path, value);
|
|
32
|
+
};
|
|
33
|
+
for (const [id, descriptor] of image.allowed) {
|
|
34
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid registry seed digest ${id}`);
|
|
35
|
+
const hex = id.slice(7);
|
|
36
|
+
const info = await stat(`${contentDirectory}/${hex}`);
|
|
37
|
+
if (!info.isFile() || info.size !== descriptor.size) throw new Error(`missing or incomplete registry seed blob ${id}`);
|
|
38
|
+
const destination = `${root}/blobs/sha256/${hex.slice(0, 2)}/${hex}/data`;
|
|
39
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
40
|
+
try { await symlink(`${mountedContent}/${hex}`, destination); }
|
|
41
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
42
|
+
await link(`${repo}/_layers/sha256/${hex}/link`, id);
|
|
43
|
+
if (image.metadata.has(id)) await link(`${repo}/_manifests/revisions/sha256/${hex}/link`, id);
|
|
44
|
+
}
|
|
45
|
+
const id = image.target.digest;
|
|
46
|
+
await link(`${repo}/_manifests/tags/${tag}/index/sha256/${id.slice(7)}/link`, id);
|
|
47
|
+
await link(`${repo}/_manifests/tags/${tag}/current/link`, id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function seedRegistry(
|
|
51
|
+
service: string, contentDirectory: string, images: readonly PreparedComponentImage[],
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
// Sequential metadata updates avoid races when several tags share blobs.
|
|
54
|
+
// Builds themselves still run concurrently with all environment preparation.
|
|
55
|
+
for (const image of images) {
|
|
56
|
+
await image.ready;
|
|
57
|
+
const built = await hostImage(image.source);
|
|
58
|
+
await seedRegistryImage(registryDirectory(service), contentDirectory, image.reference, built);
|
|
59
|
+
console.log(`[k3s-registry] ${service}: ${image.reference} @ ${built.target.digest} (cached blobs, no export)`);
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1250,7 +1250,8 @@ export type BuildImage = (
|
|
|
1250
1250
|
) & {
|
|
1251
1251
|
/** Full image reference, e.g. "k8s.internal:5000/runtime:spectest".
|
|
1252
1252
|
* Optional for a service; required for a Kubernetes build. This names a
|
|
1253
|
-
* local image
|
|
1253
|
+
* local image. In k3s, builds targeting `registry.host` are also published
|
|
1254
|
+
* to that component's registry; other references are only preloaded. */
|
|
1254
1255
|
reference?: string;
|
|
1255
1256
|
};
|
|
1256
1257
|
|