@specific.dev/spectest 0.90.3 → 0.91.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/dist/android-step.js +2 -2
- package/dist/components/expo.d.ts +2 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/k3s-trust.d.ts +57 -0
- package/dist/components/k3s-trust.js +125 -0
- package/dist/components/k3s.d.ts +17 -5
- package/dist/components/k3s.js +126 -53
- package/dist/daemon.js +71 -28
- package/dist/harness/component-images.d.ts +34 -0
- package/dist/harness/component-images.js +69 -0
- package/dist/harness/concurrency.d.ts +2 -0
- package/dist/harness/concurrency.js +9 -0
- package/dist/harness/k3s-image-loader.d.ts +3 -0
- package/dist/harness/k3s-image-loader.js +152 -0
- package/dist/harness/k3s-prepull.d.ts +8 -0
- package/dist/harness/k3s-prepull.js +35 -0
- package/dist/harness/sticky-consumer.d.ts +3 -3
- package/dist/harness/sticky-consumer.js +19 -4
- package/dist/harness/sticky-kubernetes.d.ts +15 -0
- package/dist/harness/sticky-kubernetes.js +163 -0
- package/dist/harness/sticky-paths.d.ts +5 -0
- package/dist/harness/sticky-paths.js +18 -0
- package/dist/index.d.ts +32 -2
- package/dist/index.js +25 -2
- package/package.json +1 -1
- package/src/android-step.test.ts +3 -3
- package/src/android-step.ts +2 -2
- package/src/android.test.ts +3 -3
- package/src/components/index.ts +1 -0
- package/src/components/k3s-concurrency.test.ts +60 -0
- package/src/components/k3s-trust.test.ts +81 -0
- package/src/components/k3s-trust.ts +135 -0
- package/src/components/k3s.ts +131 -51
- package/src/daemon.ts +72 -27
- package/src/harness/component-images.test.ts +44 -0
- package/src/harness/component-images.ts +79 -0
- package/src/harness/concurrency.ts +8 -0
- package/src/harness/k3s-image-loader.ts +131 -0
- package/src/harness/k3s-prepull.test.ts +57 -0
- package/src/harness/k3s-prepull.ts +33 -0
- package/src/harness/sticky-consumer.test.ts +28 -0
- package/src/harness/sticky-consumer.ts +14 -3
- package/src/harness/sticky-kubernetes.test.ts +88 -0
- package/src/harness/sticky-kubernetes.ts +165 -0
- package/src/harness/sticky-paths.ts +18 -0
- package/src/index.ts +48 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** Kubernetes owns a separate containerd store; Docker/BuildKit GC cannot
|
|
2
|
+
* collect it. Maintenance runs only on the isolated publication clone. */
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { STICKY_ROOT } from "./sticky-paths.js";
|
|
8
|
+
|
|
9
|
+
export type CacheCommand = (program: string, args: string[], timeout?: number) => Promise<string>;
|
|
10
|
+
const STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
|
|
11
|
+
const SOCKET = "/run/spectest-cache.sock";
|
|
12
|
+
const ROOT = `${STICKY_ROOT}/kubernetes`;
|
|
13
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
14
|
+
const SERVICE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
15
|
+
interface StoreDescriptor { schema: 1; image: string; updatedAt: number }
|
|
16
|
+
|
|
17
|
+
export function stickyKubernetesDirectory(service: string, version: string): string {
|
|
18
|
+
// Version changes must not open old metadata with a different containerd.
|
|
19
|
+
const layout = createHash("sha256").update(`overlayfs-v1:${version}`).digest("hex").slice(0, 16);
|
|
20
|
+
return `${ROOT}/${service}/${layout}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const runCommand: CacheCommand = async (program, args, timeout = 30_000) =>
|
|
24
|
+
(await promisify(execFile)(program, args, { timeout })).stdout.trim();
|
|
25
|
+
|
|
26
|
+
export async function registerStickyKubernetesStore(
|
|
27
|
+
service: string, version: string, run: CacheCommand = runCommand,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
if (!SERVICE_NAME.test(service)) throw new Error(`invalid Kubernetes cache service name: ${service}`);
|
|
30
|
+
// Respect custom Dockerfile overrides of the component's image.
|
|
31
|
+
const image = (await run("docker", ["inspect", "--format", "{{.Image}}", service])).trim();
|
|
32
|
+
if (!IMAGE_ID.test(image)) throw new Error(`cannot identify Kubernetes runtime image for ${service}`);
|
|
33
|
+
const directory = stickyKubernetesDirectory(service, version);
|
|
34
|
+
await mkdir(directory, { recursive: true });
|
|
35
|
+
const temporary = `${directory}/store.${randomUUID()}.tmp`;
|
|
36
|
+
await writeFile(temporary, JSON.stringify({ schema: 1, image, updatedAt: Date.now() } satisfies StoreDescriptor));
|
|
37
|
+
await rename(temporary, `${directory}/store.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Standalone containerd never starts CRI/kubelet or any inherited workload.
|
|
41
|
+
* Native API deletion preserves content and committed image snapshots while
|
|
42
|
+
* releasing sandbox, container, lease and writable snapshot references. */
|
|
43
|
+
export const KUBERNETES_CACHE_MAINTENANCE_SH = `set -eu
|
|
44
|
+
exec 2>&1
|
|
45
|
+
cat > /run/cache.toml <<'CONFIG'
|
|
46
|
+
version = 2
|
|
47
|
+
disabled_plugins = ["io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "io.containerd.cri.v1.images"]
|
|
48
|
+
CONFIG
|
|
49
|
+
containerd --config /run/cache.toml --root ${STORE_TARGET} --state /run/cache-state --address ${SOCKET} > /run/cache.log 2>&1 &
|
|
50
|
+
pid=$!
|
|
51
|
+
stop() { kill "$pid"; wait "$pid"; }
|
|
52
|
+
trap 'stop; exit 0' TERM INT
|
|
53
|
+
trap 'kill "$pid" 2>/dev/null || true' EXIT
|
|
54
|
+
c() { ctr --address ${SOCKET} --namespace k8s.io "$@"; }
|
|
55
|
+
i=0
|
|
56
|
+
until c version >/dev/null 2>&1; do
|
|
57
|
+
kill -0 "$pid" || { cat /run/cache.log; exit 1; }
|
|
58
|
+
i=$((i+1)); [ "$i" -lt 300 ] || { cat /run/cache.log; exit 1; }
|
|
59
|
+
sleep 0.1
|
|
60
|
+
done
|
|
61
|
+
ids=$(c containers ls -q)
|
|
62
|
+
if [ -n "$ids" ]; then c containers rm $ids; fi
|
|
63
|
+
# 1.7 has no sandbox store; 2.x does.
|
|
64
|
+
case "$(containerd --version)" in
|
|
65
|
+
*v1.*) ;;
|
|
66
|
+
*) sandboxes=$(c sandboxes ls)
|
|
67
|
+
ids=$(printf '%s\\n' "$sandboxes" | awk 'NR > 1 {print $1}')
|
|
68
|
+
if [ -n "$ids" ]; then c sandboxes rm --force $ids; fi ;;
|
|
69
|
+
esac
|
|
70
|
+
ids=$(c leases ls -q)
|
|
71
|
+
if [ -n "$ids" ]; then c leases rm $ids; fi
|
|
72
|
+
# Remove children before parents, and tolerate asynchronous GC winning a race.
|
|
73
|
+
previous=''
|
|
74
|
+
while :; do
|
|
75
|
+
snapshots=$(c snapshots --snapshotter overlayfs ls)
|
|
76
|
+
ids=$(printf '%s\\n' "$snapshots" | awk 'NR > 1 && $NF != "Committed" {print $1}')
|
|
77
|
+
[ -n "$ids" ] || break
|
|
78
|
+
[ "$ids" != "$previous" ] || { echo 'cannot clean Kubernetes writable snapshots' >&2; exit 1; }
|
|
79
|
+
previous="$ids"
|
|
80
|
+
for id in $ids; do c snapshots --snapshotter overlayfs rm "$id" >/dev/null 2>&1 || true; done
|
|
81
|
+
done
|
|
82
|
+
touch /run/cache-ready
|
|
83
|
+
while :; do sleep 1 & wait $!; done
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
interface Store { directory: string; descriptor: StoreDescriptor; container: string; active: boolean }
|
|
87
|
+
export interface KubernetesCacheMaintenance {
|
|
88
|
+
/** Outer images needed by live maintenance containers must not be evicted. */
|
|
89
|
+
protectedImages: Set<string>;
|
|
90
|
+
/** Evicts one complete image store, oldest service/version first. */
|
|
91
|
+
pruneOldest(): Promise<boolean>;
|
|
92
|
+
close(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function openKubernetesCacheMaintenance(run: CacheCommand, root = ROOT): Promise<KubernetesCacheMaintenance> {
|
|
96
|
+
const stores: Store[] = [];
|
|
97
|
+
const protectedImages = new Set<string>();
|
|
98
|
+
const closeStore = async (store: Store) => {
|
|
99
|
+
if (!store.active) return;
|
|
100
|
+
await run("docker", ["stop", "--time", "30", store.container], 45_000);
|
|
101
|
+
const status = JSON.parse(await run("docker", ["inspect", "--format", "{{json .State}}", store.container]));
|
|
102
|
+
if (status.ExitCode !== 0 || status.OOMKilled) throw new Error(`unclean Kubernetes cache shutdown: ${store.container}`);
|
|
103
|
+
await run("docker", ["rm", store.container]);
|
|
104
|
+
store.active = false;
|
|
105
|
+
if (!stores.some(other => other.active && other.descriptor.image === store.descriptor.image)) protectedImages.delete(store.descriptor.image);
|
|
106
|
+
// These contain runtime state, not image content or unpacked layers.
|
|
107
|
+
await run("rm", ["-rf", ...["io.containerd.runtime.v2.task", "io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "tmpmounts"]
|
|
108
|
+
.map(child => `${store.directory}/containerd/${child}`)]);
|
|
109
|
+
};
|
|
110
|
+
const close = async () => {
|
|
111
|
+
const failures: unknown[] = [];
|
|
112
|
+
for (const store of stores) {
|
|
113
|
+
try { await closeStore(store); } catch (error) { failures.push(error); }
|
|
114
|
+
}
|
|
115
|
+
if (failures.length) throw new AggregateError(failures, "Kubernetes cache shutdown failed");
|
|
116
|
+
};
|
|
117
|
+
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
|
118
|
+
if (error.code === "ENOENT") return [];
|
|
119
|
+
throw error;
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
for (const service of entries) {
|
|
123
|
+
if (!service.isDirectory() || !SERVICE_NAME.test(service.name)) continue;
|
|
124
|
+
for (const layout of await readdir(`${root}/${service.name}`, { withFileTypes: true })) {
|
|
125
|
+
if (!layout.isDirectory() || !/^[a-f0-9]{16}$/.test(layout.name)) continue;
|
|
126
|
+
const directory = `${root}/${service.name}/${layout.name}`;
|
|
127
|
+
const descriptor: StoreDescriptor = JSON.parse(await readFile(`${directory}/store.json`, "utf8"));
|
|
128
|
+
if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
|
|
129
|
+
throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
|
|
130
|
+
}
|
|
131
|
+
const container = `spectest-k8s-cache-${randomUUID()}`;
|
|
132
|
+
await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
|
|
133
|
+
"--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
|
|
134
|
+
"--entrypoint", "/bin/sh", descriptor.image, "-c", KUBERNETES_CACHE_MAINTENANCE_SH]);
|
|
135
|
+
stores.push({ directory, descriptor, container, active: true });
|
|
136
|
+
protectedImages.add(descriptor.image);
|
|
137
|
+
try {
|
|
138
|
+
await run("docker", ["exec", container, "sh", "-c", "i=0; until [ -f /run/cache-ready ]; do i=$((i+1)); [ $i -lt 900 ] || exit 1; sleep 0.1; done"], 100_000);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const logs = await run("docker", ["logs", "--tail", "40", container]).catch(() => "");
|
|
141
|
+
throw new Error(`Kubernetes cache preparation failed for ${directory}: ${logs}`, { cause: error });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
stores.sort((a, b) => a.descriptor.updatedAt - b.descriptor.updatedAt || a.directory.localeCompare(b.directory));
|
|
146
|
+
let next = 0;
|
|
147
|
+
const pruneOldest = async () => {
|
|
148
|
+
const store = stores[next++];
|
|
149
|
+
if (!store) return false;
|
|
150
|
+
await run("docker", ["exec", store.container, "sh", "-ec",
|
|
151
|
+
`ids=$(ctr -a ${SOCKET} -n k8s.io images ls -q); if [ -n "$ids" ]; then ctr -a ${SOCKET} -n k8s.io images rm --sync $ids; fi`], 120_000);
|
|
152
|
+
await closeStore(store);
|
|
153
|
+
// No writer remains. Discard the now-empty cache and its descriptor so
|
|
154
|
+
// obsolete k3s binaries can also be collected by the outer Docker GC.
|
|
155
|
+
await run("rm", ["-rf", store.directory]);
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
// Abandoned services/versions should not occupy the disk indefinitely.
|
|
159
|
+
while (stores[next] && stores[next]!.descriptor.updatedAt < Date.now() - 7 * 24 * 60 * 60 * 1000) await pruneOldest();
|
|
160
|
+
return { protectedImages, pruneOldest, close };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await close().catch(() => {});
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const STICKY_ROOT = "/var/lib/spectest-sticky";
|
|
5
|
+
export const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
6
|
+
|
|
7
|
+
/** Written only after the supervisor mounts this project's private disk. */
|
|
8
|
+
export function stickyDiskPathSync(manifest = `${STICKY_RUNTIME}/consumer.json`): string | null {
|
|
9
|
+
try {
|
|
10
|
+
const config = JSON.parse(readFileSync(manifest, "utf8"));
|
|
11
|
+
return config.path === STICKY_ROOT ? STICKY_ROOT : null;
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isOnStickyDisk(path: string, root = stickyDiskPathSync()): boolean {
|
|
16
|
+
const absolute = resolve(path);
|
|
17
|
+
return root !== null && (absolute === root || absolute.startsWith(`${root}/`));
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -253,6 +253,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
253
253
|
export interface ServiceConfig {
|
|
254
254
|
/** Image definition: registry pull or inline build. */
|
|
255
255
|
image: ServiceImage;
|
|
256
|
+
/** @internal Images built by the harness and loaded into this component. */
|
|
257
|
+
imageBuilds?: readonly ComponentImageBuild[];
|
|
256
258
|
/**
|
|
257
259
|
* Shell command to run (sh -c). Defaults to the image's CMD. NB: this
|
|
258
260
|
* **replaces the image's entrypoint** with `/bin/sh -c` — an
|
|
@@ -1158,10 +1160,13 @@ export interface ServiceTls {
|
|
|
1158
1160
|
port: number;
|
|
1159
1161
|
}
|
|
1160
1162
|
|
|
1161
|
-
|
|
1163
|
+
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
1164
|
+
export type NormalizedServiceImage =
|
|
1162
1165
|
| { type: "registry"; reference: string }
|
|
1163
1166
|
| {
|
|
1164
1167
|
type: "dockerfile";
|
|
1168
|
+
/** Optional full local image reference. */
|
|
1169
|
+
reference?: string;
|
|
1165
1170
|
/**
|
|
1166
1171
|
* Path of a Dockerfile in your repo, relative to the project root
|
|
1167
1172
|
* (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
|
|
@@ -1204,6 +1209,8 @@ export type ServiceImage =
|
|
|
1204
1209
|
}
|
|
1205
1210
|
| {
|
|
1206
1211
|
type: "dockerfile";
|
|
1212
|
+
/** Optional full local image reference. */
|
|
1213
|
+
reference?: string;
|
|
1207
1214
|
/**
|
|
1208
1215
|
* Dockerfile contents, built as if the file sat outside the build
|
|
1209
1216
|
* context (it is never written into your repo). The context is the
|
|
@@ -1233,6 +1240,38 @@ export type ServiceImage =
|
|
|
1233
1240
|
buildArgs?: Readonly<Record<string, string>>;
|
|
1234
1241
|
};
|
|
1235
1242
|
|
|
1243
|
+
/** Dockerfile build options shared by services and Kubernetes images. */
|
|
1244
|
+
export type BuildImage = (
|
|
1245
|
+
| Omit<Extract<NormalizedServiceImage, { type: "dockerfile"; path: string }>, "type">
|
|
1246
|
+
| Omit<Extract<NormalizedServiceImage, { type: "dockerfile"; content: string }>, "type">
|
|
1247
|
+
) & {
|
|
1248
|
+
/** Full image reference, e.g. "k8s.internal:5000/runtime:spectest".
|
|
1249
|
+
* Optional for a service; required for a Kubernetes build. This names a
|
|
1250
|
+
* local image and does not publish it to a registry. */
|
|
1251
|
+
reference?: string;
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
/** A registry reference or Dockerfile build. Tagged objects from older SDKs
|
|
1255
|
+
* remain accepted for compatibility; new definitions do not need `type`. */
|
|
1256
|
+
export type ServiceImage = string | BuildImage | NormalizedServiceImage;
|
|
1257
|
+
|
|
1258
|
+
/** @internal Keep the wire format compatible with existing control planes. */
|
|
1259
|
+
export function normalizeServiceImage(image: ServiceImage): NormalizedServiceImage {
|
|
1260
|
+
if (typeof image === "string") return { type: "registry", reference: image };
|
|
1261
|
+
if (!image || typeof image !== "object" || Array.isArray(image)) throw new Error("image must be a registry reference or Dockerfile build");
|
|
1262
|
+
if ("type" in image) {
|
|
1263
|
+
if (image.type !== "registry" && image.type !== "dockerfile") throw new Error("unsupported image type");
|
|
1264
|
+
return image;
|
|
1265
|
+
}
|
|
1266
|
+
return { ...image, type: "dockerfile" };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** @internal Serializes build-only work with the owning component. */
|
|
1270
|
+
export interface ComponentImageBuild {
|
|
1271
|
+
reference: string;
|
|
1272
|
+
image: BuildImage;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1236
1275
|
/** A Dockerfile `ARG` name: what `docker build --build-arg` accepts as a key. */
|
|
1237
1276
|
const BUILD_ARG_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1238
1277
|
|
|
@@ -1274,8 +1313,13 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
|
|
|
1274
1313
|
* checks go through the project-file resolver, so the same rules as
|
|
1275
1314
|
* `ctx.readProjectFile` apply.
|
|
1276
1315
|
*/
|
|
1277
|
-
export function validateServiceImage(serviceName: string,
|
|
1316
|
+
export function validateServiceImage(serviceName: string, input: ServiceImage): NormalizedServiceImage {
|
|
1317
|
+
const image = normalizeServiceImage(input);
|
|
1318
|
+
const reference = typeof input === "string" ? input : "reference" in input ? input.reference : undefined;
|
|
1319
|
+
if (reference !== undefined && (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference))) throw new Error(`service "${serviceName}" image reference must be a non-empty image name without whitespace`);
|
|
1278
1320
|
if (image.type !== "dockerfile") return image;
|
|
1321
|
+
if (reference?.includes("@")) throw new Error(`service "${serviceName}" build reference must be an image name/tag, not a digest`);
|
|
1322
|
+
if (image.exclude !== undefined && (!Array.isArray(image.exclude) || image.exclude.some(e => typeof e !== "string"))) throw new Error(`service "${serviceName}" image exclude must be an array of glob patterns`);
|
|
1279
1323
|
validateBuildArgs(serviceName, image.buildArgs);
|
|
1280
1324
|
const hasPath = typeof image.path === "string";
|
|
1281
1325
|
const hasContent = typeof image.content === "string";
|
|
@@ -1286,7 +1330,7 @@ export function validateServiceImage(serviceName: string, image: ServiceImage):
|
|
|
1286
1330
|
}
|
|
1287
1331
|
if (!hasPath && !hasContent) {
|
|
1288
1332
|
throw new Error(
|
|
1289
|
-
`service "${serviceName}" image
|
|
1333
|
+
`service "${serviceName}" image build needs \`path\` (a Dockerfile in your repo, relative to the project root) or \`content\``,
|
|
1290
1334
|
);
|
|
1291
1335
|
}
|
|
1292
1336
|
if (hasPath) {
|
|
@@ -2491,6 +2535,7 @@ export function defineEnvironment<
|
|
|
2491
2535
|
const { fakes, services: rawServices, ...rest } = input;
|
|
2492
2536
|
const expanded = expandServiceGroups(rawServices);
|
|
2493
2537
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
2538
|
+
for (const build of svc.imageBuilds ?? []) validateServiceImage(`${key} image ${build.reference}`, build.image);
|
|
2494
2539
|
expanded[key] = applyCoverageAdapters(key, {
|
|
2495
2540
|
...svc,
|
|
2496
2541
|
image: validateServiceImage(key, svc.image),
|