@specific.dev/spectest 0.74.0 → 0.75.0

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
@@ -50,7 +50,7 @@ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectC
50
50
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
51
51
  import { startTlsTerminator } from "./harness/tls-terminator.js";
52
52
  import { runContainerArgs } from "./harness/container-run.js";
53
- import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
53
+ import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, buildkitCacheTag, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
54
54
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
55
55
  import { conflict, notFound, requireString, } from "./harness/methods.js";
56
56
  import { openTerminal } from "./terminal.js";
@@ -1107,7 +1107,15 @@ async function runServiceBuild(name, image, tag) {
1107
1107
  // disk is what outlives the VM; `mode=max` keeps every
1108
1108
  // intermediate layer, uncompressed so an import never inflates,
1109
1109
  // and one tag per service so exports do not replace each other.
1110
- const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1110
+ // The tag carries the `spectest/service/` prefix because the
1111
+ // directory is one flat tag namespace the project's own BuildKit
1112
+ // writes into too (the docs tell it to mount this path): tags merge
1113
+ // newest-wins with no warning anywhere, and a project naturally tags
1114
+ // a build after the thing it builds, which is also what its service
1115
+ // is named. Specific's `nginx-mod` service tagged its own buildctl
1116
+ // cache `nginx-mod` and the two overwrote each other on alternate
1117
+ // boots (reported 2026-09-06). The prefix is reserved in the docs.
1118
+ const cacheTag = buildkitCacheTag(name);
1111
1119
  cacheExport = [
1112
1120
  "--cache-to",
1113
1121
  `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
@@ -56,6 +56,23 @@ export declare const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
56
56
  * path for both of its cache flags.
57
57
  */
58
58
  export declare const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
59
+ /**
60
+ * The prefix under which the harness tags its own exports in that
61
+ * directory. It is one flat tag namespace shared with the project's own
62
+ * BuildKit (the `/services` docs page tells it to mount the union), tags
63
+ * merge newest-wins with no warning, and a project naturally tags a build
64
+ * after the thing it builds — which is what its spectest service is named
65
+ * too. So the harness's tags live under a prefix no project would choose,
66
+ * and the docs reserve it. Reported by Specific 2026-09-06: a service
67
+ * `nginx-mod` running `buildctl` tagged its cache `nginx-mod`, and it and
68
+ * the harness's image build for that very container overwrote each other
69
+ * on alternate boots.
70
+ */
71
+ export declare const BUILDKIT_CACHE_TAG_PREFIX = "spectest/service/";
72
+ /** The BuildKit cache tag for a service's Dockerfile build: the prefix plus
73
+ * the service name, lowercased with anything outside `[a-z0-9-]` folded
74
+ * to `-`. */
75
+ export declare function buildkitCacheTag(service: string): string;
59
76
  /** The cache's paths, or `null` when this VM carries none.
60
77
  * `SPECTEST_IMAGE_CACHE_MANIFEST` points a test at another file. */
61
78
  export declare function imageCachePathsSync(manifest?: string): ImageCachePaths | null;
@@ -50,6 +50,25 @@ export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
50
50
  * path for both of its cache flags.
51
51
  */
52
52
  export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
53
+ /**
54
+ * The prefix under which the harness tags its own exports in that
55
+ * directory. It is one flat tag namespace shared with the project's own
56
+ * BuildKit (the `/services` docs page tells it to mount the union), tags
57
+ * merge newest-wins with no warning, and a project naturally tags a build
58
+ * after the thing it builds — which is what its spectest service is named
59
+ * too. So the harness's tags live under a prefix no project would choose,
60
+ * and the docs reserve it. Reported by Specific 2026-09-06: a service
61
+ * `nginx-mod` running `buildctl` tagged its cache `nginx-mod`, and it and
62
+ * the harness's image build for that very container overwrote each other
63
+ * on alternate boots.
64
+ */
65
+ export const BUILDKIT_CACHE_TAG_PREFIX = "spectest/service/";
66
+ /** The BuildKit cache tag for a service's Dockerfile build: the prefix plus
67
+ * the service name, lowercased with anything outside `[a-z0-9-]` folded
68
+ * to `-`. */
69
+ export function buildkitCacheTag(service) {
70
+ return `${BUILDKIT_CACHE_TAG_PREFIX}${service.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
71
+ }
53
72
  function parse(raw) {
54
73
  const parsed = JSON.parse(raw);
55
74
  const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.74.0",
3
+ "version": "0.75.0",
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
@@ -138,6 +138,7 @@ import { runContainerArgs } from "./harness/container-run.js";
138
138
  import {
139
139
  BUILDKIT_CACHE_DIR,
140
140
  BUILDKIT_CACHE_UNION,
141
+ buildkitCacheTag,
141
142
  IMAGE_CACHE_MANIFEST,
142
143
  imageCachePathsSync,
143
144
  isOnImageCache,
@@ -1456,7 +1457,15 @@ async function runServiceBuild(
1456
1457
  // disk is what outlives the VM; `mode=max` keeps every
1457
1458
  // intermediate layer, uncompressed so an import never inflates,
1458
1459
  // and one tag per service so exports do not replace each other.
1459
- const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1460
+ // The tag carries the `spectest/service/` prefix because the
1461
+ // directory is one flat tag namespace the project's own BuildKit
1462
+ // writes into too (the docs tell it to mount this path): tags merge
1463
+ // newest-wins with no warning anywhere, and a project naturally tags
1464
+ // a build after the thing it builds, which is also what its service
1465
+ // is named. Specific's `nginx-mod` service tagged its own buildctl
1466
+ // cache `nginx-mod` and the two overwrote each other on alternate
1467
+ // boots (reported 2026-09-06). The prefix is reserved in the docs.
1468
+ const cacheTag = buildkitCacheTag(name);
1460
1469
  cacheExport = [
1461
1470
  "--cache-to",
1462
1471
  `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
@@ -28,7 +28,7 @@ describe("isOnImageCache", () => {
28
28
  });
29
29
  });
30
30
 
31
- import { mergeCacheIndex, type OciIndex } from "./image-cache";
31
+ import { BUILDKIT_CACHE_TAG_PREFIX, buildkitCacheTag, mergeCacheIndex, type OciIndex } from "./image-cache";
32
32
 
33
33
  const entry = (tag: string, hex: string) => ({ mediaType: "application/vnd.oci.image.manifest.v1+json", digest: `sha256:${hex}`, size: 1, annotations: { "org.opencontainers.image.ref.name": tag } });
34
34
 
@@ -62,4 +62,20 @@ describe("mergeCacheIndex", () => {
62
62
  const out = mergeCacheIndex(lower, upper, () => true);
63
63
  expect(out.manifests.map((m) => m.annotations?.["org.opencontainers.image.ref.name"])).toEqual(["api", "cli", "worker"]);
64
64
  });
65
+
66
+ /** The directory is one tag namespace shared with the project's own
67
+ * BuildKit. The harness's tags are prefixed so a project tag named after
68
+ * the same service sits beside it instead of replacing it. */
69
+ test("a project's tag and the harness's tag for the same service both survive", () => {
70
+ const both: OciIndex = { schemaVersion: 2, manifests: [entry("nginx-mod", "p1"), entry(buildkitCacheTag("nginx-mod"), "h1")] };
71
+ const out = mergeCacheIndex(both, null, () => false);
72
+ expect(out.manifests.map((m) => m.digest)).toEqual(["sha256:p1", "sha256:h1"]);
73
+ });
74
+ });
75
+
76
+ describe("buildkitCacheTag", () => {
77
+ test("the reserved prefix plus the normalised service name", () => {
78
+ expect(buildkitCacheTag("nginx-mod")).toBe(`${BUILDKIT_CACHE_TAG_PREFIX}nginx-mod`);
79
+ expect(buildkitCacheTag("Api_v2")).toBe("spectest/service/api-v2");
80
+ });
65
81
  });
@@ -66,6 +66,27 @@ export const BUILDKIT_CACHE_DIR = "spectest-buildkit-cache";
66
66
  */
67
67
  export const BUILDKIT_CACHE_UNION = "/var/lib/spectest-buildkit-cache";
68
68
 
69
+ /**
70
+ * The prefix under which the harness tags its own exports in that
71
+ * directory. It is one flat tag namespace shared with the project's own
72
+ * BuildKit (the `/services` docs page tells it to mount the union), tags
73
+ * merge newest-wins with no warning, and a project naturally tags a build
74
+ * after the thing it builds — which is what its spectest service is named
75
+ * too. So the harness's tags live under a prefix no project would choose,
76
+ * and the docs reserve it. Reported by Specific 2026-09-06: a service
77
+ * `nginx-mod` running `buildctl` tagged its cache `nginx-mod`, and it and
78
+ * the harness's image build for that very container overwrote each other
79
+ * on alternate boots.
80
+ */
81
+ export const BUILDKIT_CACHE_TAG_PREFIX = "spectest/service/";
82
+
83
+ /** The BuildKit cache tag for a service's Dockerfile build: the prefix plus
84
+ * the service name, lowercased with anything outside `[a-z0-9-]` folded
85
+ * to `-`. */
86
+ export function buildkitCacheTag(service: string): string {
87
+ return `${BUILDKIT_CACHE_TAG_PREFIX}${service.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`;
88
+ }
89
+
69
90
  function parse(raw: string): ImageCachePaths | null {
70
91
  const parsed = JSON.parse(raw) as { disks?: { role?: string; path?: string }[] };
71
92
  const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;