@specific.dev/spectest 0.131.13 → 0.131.14

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/dist/daemon.js CHANGED
@@ -5,7 +5,7 @@ import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.
5
5
  import { runWithBackgroundFailures } from "./harness/background-failures.js";
6
6
  import { generateHostCert as mintHostCert } from "./harness/certificate.js";
7
7
  import { containerdImageOutput } from "./harness/built-image.js";
8
- import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
8
+ import { componentBuildTasks, COMPONENT_RUNTIME, isComponentImageTask, isDigestReference } from "./harness/component-images.js";
9
9
  import { waitForAll } from "./harness/concurrency.js";
10
10
  import { updateServiceProgress, serviceProgressSnapshot } from "./harness/service-progress.js";
11
11
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
@@ -492,6 +492,18 @@ function ensureLocalBuildkitd() {
492
492
  })();
493
493
  return _localBuilder;
494
494
  }
495
+ /** `true` when a digest-pinned reference is already complete in the store. */
496
+ async function imagePresentByDigest(ref) {
497
+ if (!isDigestReference(ref))
498
+ return false;
499
+ // `inspect` succeeds only when every layer of the image for this
500
+ // platform is in the store; a partial or missing image falls through to
501
+ // the ordinary pull, which fetches what is absent. dockerd's containerd
502
+ // store also unpacks on demand at container creation, so a record whose
503
+ // snapshots were reclaimed is repaired there rather than failing.
504
+ const present = await docker(["image", "inspect", "--format", "{{.Id}}", ref], 30_000);
505
+ return present.code === 0;
506
+ }
495
507
  /** Host directory backing one volume mount. Rules (notably which volumes
496
508
  * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
497
509
  function resolveHostPath(service, vol) {
@@ -898,6 +910,19 @@ async function prepareServiceImageInner(svc, opts) {
898
910
  // tags (`foo:latest`) at whatever the previous generation pulled, for
899
911
  // as long as the delta chain lives — a silent semantic divergence
900
912
  // from the cold build a delta restore must be equivalent to.
913
+ //
914
+ // The one reference that cannot float is a digest. `name@sha256:…`
915
+ // names exactly one image for as long as it exists, so a copy already
916
+ // in the store is the image the registry would return, and the
917
+ // round-trip buys nothing: skip it. Component images pinned by digest
918
+ // and user references pinned by digest take this path; a tag never does.
919
+ if (await imagePresentByDigest(ref)) {
920
+ progressService(svc.name, { status: "pulling", detail: `present by digest` });
921
+ const tagged = await docker(["tag", ref, tag], 30_000);
922
+ if (tagged.code !== 0)
923
+ throw new Error(`docker tag ${ref} ${tag} failed: ${tagged.stderr.trim()}`);
924
+ return { tag };
925
+ }
901
926
  progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
902
927
  let layers = 0;
903
928
  const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
@@ -1042,11 +1067,30 @@ async function runServiceBuild(name, image, tag) {
1042
1067
  // containerd exporter replaces the tag directly, bypassing Docker's
1043
1068
  // dangling-image retention. Old versions must remain usable until our
1044
1069
  // image GC decides they are no longer needed (including by nested stores).
1070
+ //
1071
+ // `compression=uncompressed` (`containerdImageOutput`): the image never leaves this store, so
1072
+ // gzip on export only to gunzip on unpack is pure cost — on Listen,
1073
+ // 64 s compressing a 2.7 GB layer and 56 s decompressing it again,
1074
+ // single-threaded, in the same guest that already held the layer as
1075
+ // a build snapshot (CONTAINER_STORE.md, "Image delivery inside the
1076
+ // guest"). With the default `force-compression=false` this applies
1077
+ // to layers this build creates; a pulled base image's gzip blobs are
1078
+ // reused as they are, so no existing layer is re-encoded or
1079
+ // re-unpacked. The uncompressed blob is a second on-disk copy of the
1080
+ // layer; the sticky headroom logic already budgets for blobs.
1081
+ //
1082
+ // `unpack`: dockerd needs the snapshot chain to start a container,
1083
+ // so a service image is unpacked here rather than at `docker run`.
1084
+ // A component image task (a `k3s()` build) is consumed only through
1085
+ // the content store — `k3s-image-loader.ts` serves its blobs and the
1086
+ // cluster's containerd unpacks them into its own store — so unpacking
1087
+ // it in dockerd's store would be a third copy nobody reads.
1088
+ const unpack = !isComponentImageTask(name);
1045
1089
  buildArgs = [
1046
1090
  "buildx", "build",
1047
1091
  "--builder", LOCAL_BUILDER_NAME,
1048
1092
  "--progress=plain",
1049
- "--output", containerdImageOutput(tag),
1093
+ "--output", containerdImageOutput(tag, { unpack }),
1050
1094
  ...argFlags,
1051
1095
  "-f", dfPath, contextDir,
1052
1096
  ];
@@ -1,6 +1,15 @@
1
1
  /** Containerd exports replace mutable tags without Docker's dangling-image
2
- * retention. Keep each result addressable until the SDK's image GC evicts it. */
3
- export declare function containerdImageOutput(reference: string): string;
2
+ * retention. Keep each result addressable until the SDK's image GC evicts it.
3
+ *
4
+ * Layers this build creates are stored uncompressed: the image never leaves
5
+ * the VM's store, so compressing on export only to decompress on unpack was
6
+ * pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
7
+ * off, so a pulled base image's blobs are reused as they are. `unpack` is
8
+ * false for images only a nested store reads (CONTAINER_STORE.md, "Image
9
+ * delivery inside the guest"). */
10
+ export declare function containerdImageOutput(reference: string, options?: {
11
+ unpack?: boolean;
12
+ }): string;
4
13
  export interface Descriptor {
5
14
  digest: string;
6
15
  size: number;
@@ -4,9 +4,17 @@ import { createHash } from "node:crypto";
4
4
  import { promisify } from "node:util";
5
5
  import { canonicalImageReference } from "./component-images.js";
6
6
  /** Containerd exports replace mutable tags without Docker's dangling-image
7
- * retention. Keep each result addressable until the SDK's image GC evicts it. */
8
- export function containerdImageOutput(reference) {
9
- return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=true`;
7
+ * retention. Keep each result addressable until the SDK's image GC evicts it.
8
+ *
9
+ * Layers this build creates are stored uncompressed: the image never leaves
10
+ * the VM's store, so compressing on export only to decompress on unpack was
11
+ * pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
12
+ * off, so a pulled base image's blobs are reused as they are. `unpack` is
13
+ * false for images only a nested store reads (CONTAINER_STORE.md, "Image
14
+ * delivery inside the guest"). */
15
+ export function containerdImageOutput(reference, options = {}) {
16
+ const unpack = options.unpack ?? true;
17
+ return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=${unpack},compression=uncompressed`;
10
18
  }
11
19
  const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
12
20
  const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
@@ -5,10 +5,17 @@ export type KubernetesImage = string | (BuildImage & {
5
5
  });
6
6
  /** containerd/CRI use fully qualified names, including the default tag. */
7
7
  export declare function canonicalImageReference(reference: string): string;
8
+ /** A `name@sha256:…` reference names one immutable image; a tag can move. */
9
+ export declare function isDigestReference(reference: string): boolean;
8
10
  export declare function kubernetesImages(images: readonly KubernetesImage[]): {
9
11
  pulls: string[];
10
12
  builds: ComponentImageBuild[];
11
13
  };
14
+ /** Prefix of every component image task name; `isComponentImageTask` is the test. */
15
+ export declare const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
16
+ /** A component image task builds for a component's own runtime (today: a
17
+ * `k3s()` cluster's containerd), never for a container dockerd starts. */
18
+ export declare function isComponentImageTask(name: string): boolean;
12
19
  /** Stable internal task names, separate from the service graph. */
13
20
  export declare function componentBuildTasks<T extends ServiceConfig & {
14
21
  name: string;
@@ -24,6 +24,10 @@ export function canonicalImageReference(reference) {
24
24
  result += ":latest";
25
25
  return result;
26
26
  }
27
+ /** A `name@sha256:…` reference names one immutable image; a tag can move. */
28
+ export function isDigestReference(reference) {
29
+ return /@sha256:[a-f0-9]{64}$/.test(reference);
30
+ }
27
31
  export function kubernetesImages(images) {
28
32
  if (!Array.isArray(images))
29
33
  throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
@@ -54,10 +58,17 @@ export function kubernetesImages(images) {
54
58
  }
55
59
  return { pulls, builds };
56
60
  }
61
+ /** Prefix of every component image task name; `isComponentImageTask` is the test. */
62
+ export const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
63
+ /** A component image task builds for a component's own runtime (today: a
64
+ * `k3s()` cluster's containerd), never for a container dockerd starts. */
65
+ export function isComponentImageTask(name) {
66
+ return name.startsWith(COMPONENT_IMAGE_TASK_PREFIX);
67
+ }
57
68
  /** Stable internal task names, separate from the service graph. */
58
69
  export function componentBuildTasks(services) {
59
70
  return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
60
- name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
71
+ name: `${COMPONENT_IMAGE_TASK_PREFIX}${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
61
72
  owner: svc.name,
62
73
  reference: build.reference,
63
74
  image: build.image,
@@ -101,6 +101,12 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
101
101
  dockerConfig["data-root"] = `${config.path}/docker`;
102
102
  dockerConfig.features = { "containerd-snapshotter": true };
103
103
  dockerConfig["storage-driver"] = "overlayfs";
104
+ // Pull up to ten layers at once instead of dockerd's default three. The
105
+ // containerd image store honours this since Docker 29.7.0; extraction is
106
+ // still one layer at a time, so this only moves the download side off
107
+ // the critical path of many-layer images. Set here as well as in the
108
+ // golden's daemon.json so an SDK release carries it without a rebuild.
109
+ dockerConfig["max-concurrent-downloads"] = 10;
104
110
  await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
105
111
  await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
106
112
  const start = async (unit, program, args) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.13",
3
+ "version": "0.131.14",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -5,7 +5,7 @@ import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecor
5
5
  import { runWithBackgroundFailures } from "./harness/background-failures.js";
6
6
  import { generateHostCert as mintHostCert } from "./harness/certificate.js";
7
7
  import { containerdImageOutput } from "./harness/built-image.js";
8
- import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
8
+ import { componentBuildTasks, COMPONENT_RUNTIME, isComponentImageTask, isDigestReference } from "./harness/component-images.js";
9
9
  import { waitForAll } from "./harness/concurrency.js";
10
10
  import { updateServiceProgress, serviceProgressSnapshot, type ServiceProgress } from "./harness/service-progress.js";
11
11
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
@@ -749,6 +749,18 @@ function ensureLocalBuildkitd(): Promise<boolean> {
749
749
  return _localBuilder;
750
750
  }
751
751
 
752
+ /** `true` when a digest-pinned reference is already complete in the store. */
753
+ async function imagePresentByDigest(ref: string): Promise<boolean> {
754
+ if (!isDigestReference(ref)) return false;
755
+ // `inspect` succeeds only when every layer of the image for this
756
+ // platform is in the store; a partial or missing image falls through to
757
+ // the ordinary pull, which fetches what is absent. dockerd's containerd
758
+ // store also unpacks on demand at container creation, so a record whose
759
+ // snapshots were reclaimed is repaired there rather than failing.
760
+ const present = await docker(["image", "inspect", "--format", "{{.Id}}", ref], 30_000);
761
+ return present.code === 0;
762
+ }
763
+
752
764
  /** Host directory backing one volume mount. Rules (notably which volumes
753
765
  * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
754
766
  function resolveHostPath(service: string, vol: VolumeMount): string {
@@ -1214,6 +1226,18 @@ async function prepareServiceImageInner(
1214
1226
  // tags (`foo:latest`) at whatever the previous generation pulled, for
1215
1227
  // as long as the delta chain lives — a silent semantic divergence
1216
1228
  // from the cold build a delta restore must be equivalent to.
1229
+ //
1230
+ // The one reference that cannot float is a digest. `name@sha256:…`
1231
+ // names exactly one image for as long as it exists, so a copy already
1232
+ // in the store is the image the registry would return, and the
1233
+ // round-trip buys nothing: skip it. Component images pinned by digest
1234
+ // and user references pinned by digest take this path; a tag never does.
1235
+ if (await imagePresentByDigest(ref)) {
1236
+ progressService(svc.name, { status: "pulling", detail: `present by digest` });
1237
+ const tagged = await docker(["tag", ref, tag], 30_000);
1238
+ if (tagged.code !== 0) throw new Error(`docker tag ${ref} ${tag} failed: ${tagged.stderr.trim()}`);
1239
+ return { tag };
1240
+ }
1217
1241
  progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
1218
1242
  let layers = 0;
1219
1243
  const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
@@ -1368,11 +1392,30 @@ async function runServiceBuild(
1368
1392
  // containerd exporter replaces the tag directly, bypassing Docker's
1369
1393
  // dangling-image retention. Old versions must remain usable until our
1370
1394
  // image GC decides they are no longer needed (including by nested stores).
1395
+ //
1396
+ // `compression=uncompressed` (`containerdImageOutput`): the image never leaves this store, so
1397
+ // gzip on export only to gunzip on unpack is pure cost — on Listen,
1398
+ // 64 s compressing a 2.7 GB layer and 56 s decompressing it again,
1399
+ // single-threaded, in the same guest that already held the layer as
1400
+ // a build snapshot (CONTAINER_STORE.md, "Image delivery inside the
1401
+ // guest"). With the default `force-compression=false` this applies
1402
+ // to layers this build creates; a pulled base image's gzip blobs are
1403
+ // reused as they are, so no existing layer is re-encoded or
1404
+ // re-unpacked. The uncompressed blob is a second on-disk copy of the
1405
+ // layer; the sticky headroom logic already budgets for blobs.
1406
+ //
1407
+ // `unpack`: dockerd needs the snapshot chain to start a container,
1408
+ // so a service image is unpacked here rather than at `docker run`.
1409
+ // A component image task (a `k3s()` build) is consumed only through
1410
+ // the content store — `k3s-image-loader.ts` serves its blobs and the
1411
+ // cluster's containerd unpacks them into its own store — so unpacking
1412
+ // it in dockerd's store would be a third copy nobody reads.
1413
+ const unpack = !isComponentImageTask(name);
1371
1414
  buildArgs = [
1372
1415
  "buildx", "build",
1373
1416
  "--builder", LOCAL_BUILDER_NAME,
1374
1417
  "--progress=plain",
1375
- "--output", containerdImageOutput(tag),
1418
+ "--output", containerdImageOutput(tag, { unpack }),
1376
1419
  ...argFlags,
1377
1420
  "-f", dfPath, contextDir,
1378
1421
  ];
@@ -5,9 +5,17 @@ import { promisify } from "node:util";
5
5
  import { canonicalImageReference } from "./component-images.js";
6
6
 
7
7
  /** Containerd exports replace mutable tags without Docker's dangling-image
8
- * retention. Keep each result addressable until the SDK's image GC evicts it. */
9
- export function containerdImageOutput(reference: string): string {
10
- return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=true`;
8
+ * retention. Keep each result addressable until the SDK's image GC evicts it.
9
+ *
10
+ * Layers this build creates are stored uncompressed: the image never leaves
11
+ * the VM's store, so compressing on export only to decompress on unpack was
12
+ * pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
13
+ * off, so a pulled base image's blobs are reused as they are. `unpack` is
14
+ * false for images only a nested store reads (CONTAINER_STORE.md, "Image
15
+ * delivery inside the guest"). */
16
+ export function containerdImageOutput(reference: string, options: { unpack?: boolean } = {}): string {
17
+ const unpack = options.unpack ?? true;
18
+ return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=${unpack},compression=uncompressed`;
11
19
  }
12
20
 
13
21
  export interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
- import { canonicalImageReference, componentBuildTasks, kubernetesImages } from "./component-images";
2
+ import { canonicalImageReference, componentBuildTasks, isComponentImageTask, isDigestReference, kubernetesImages } from "./component-images";
3
3
  import { validateServiceImage, type BuildImage } from "../index";
4
4
 
5
5
  test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
@@ -42,3 +42,21 @@ test("build task identity survives list reordering but is scoped to the owning c
42
42
  expect(() => validateServiceImage("app", { content: "FROM scratch", buildArgs: { "bad-key": "x" } })).toThrow("ARG");
43
43
  expect(() => validateServiceImage("app", { content: "FROM scratch", exclude: [1] as never })).toThrow("exclude");
44
44
  });
45
+
46
+ test("component image tasks are recognised by name and services are not", () => {
47
+ const [task] = componentBuildTasks([{ name: "cluster", image: { type: "registry", reference: "rancher/k3s:v1.33.1-k3s1" },
48
+ imageBuilds: [{ reference: "registry.internal/app:dev", image: { type: "dockerfile", content: "FROM scratch" } }] } as any]);
49
+ expect(isComponentImageTask(task!.name)).toBe(true);
50
+ expect(isComponentImageTask("cluster")).toBe(false);
51
+ expect(isComponentImageTask("spectest-image")).toBe(false);
52
+ });
53
+
54
+ test("only a full sha256 digest reference is immutable", () => {
55
+ const digest = "a".repeat(64);
56
+ expect(isDigestReference(`postgres@sha256:${digest}`)).toBe(true);
57
+ expect(isDigestReference(`ghcr.io/org/app:1.2@sha256:${digest}`)).toBe(true);
58
+ expect(isDigestReference("postgres:16.4")).toBe(false);
59
+ expect(isDigestReference("postgres:latest")).toBe(false);
60
+ expect(isDigestReference(`postgres@sha256:${digest.slice(1)}`)).toBe(false);
61
+ expect(isDigestReference(`postgres@sha512:${digest}`)).toBe(false);
62
+ });
@@ -26,6 +26,11 @@ export function canonicalImageReference(reference: string): string {
26
26
  return result;
27
27
  }
28
28
 
29
+ /** A `name@sha256:…` reference names one immutable image; a tag can move. */
30
+ export function isDigestReference(reference: string): boolean {
31
+ return /@sha256:[a-f0-9]{64}$/.test(reference);
32
+ }
33
+
29
34
  export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: string[]; builds: ComponentImageBuild[] } {
30
35
  if (!Array.isArray(images)) throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
31
36
  const pulls: string[] = [];
@@ -53,10 +58,19 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
53
58
  return { pulls, builds };
54
59
  }
55
60
 
61
+ /** Prefix of every component image task name; `isComponentImageTask` is the test. */
62
+ export const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
63
+
64
+ /** A component image task builds for a component's own runtime (today: a
65
+ * `k3s()` cluster's containerd), never for a container dockerd starts. */
66
+ export function isComponentImageTask(name: string): boolean {
67
+ return name.startsWith(COMPONENT_IMAGE_TASK_PREFIX);
68
+ }
69
+
56
70
  /** Stable internal task names, separate from the service graph. */
57
71
  export function componentBuildTasks<T extends ServiceConfig & { name: string }>(services: readonly T[]) {
58
72
  return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
59
- name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
73
+ name: `${COMPONENT_IMAGE_TASK_PREFIX}${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
60
74
  owner: svc.name,
61
75
  reference: build.reference,
62
76
  image: build.image,
@@ -104,6 +104,12 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
104
104
  dockerConfig["data-root"] = `${config.path}/docker`;
105
105
  dockerConfig.features = { "containerd-snapshotter": true };
106
106
  dockerConfig["storage-driver"] = "overlayfs";
107
+ // Pull up to ten layers at once instead of dockerd's default three. The
108
+ // containerd image store honours this since Docker 29.7.0; extraction is
109
+ // still one layer at a time, so this only moves the download side off
110
+ // the critical path of many-layer images. Set here as well as in the
111
+ // golden's daemon.json so an SDK release carries it without a rebuild.
112
+ dockerConfig["max-concurrent-downloads"] = 10;
107
113
  await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
108
114
  await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
109
115
  const start = async (unit: string, program: string, args: string[]) => {