@kici-dev/shared 0.4.0 → 0.6.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.
@@ -14,14 +14,15 @@ import { sha256 } from "@kici-dev/core";
14
14
  * at a trillion chunks, birthday probability is ~5e-8.
15
15
  */
16
16
  function computeChunkId(args) {
17
- return sha256([
17
+ const input = [
18
18
  args.db,
19
19
  args.table,
20
20
  args.tenantId,
21
21
  args.partitionDate,
22
22
  String(args.minRowId),
23
23
  String(args.maxRowId)
24
- ].join("|")).slice(0, 16);
24
+ ].join("|");
25
+ return sha256(input).slice(0, 16);
25
26
  }
26
27
  //#endregion
27
28
  export { computeChunkId };
@@ -6,9 +6,9 @@ import "../rolldown-runtime-ClRpJifh.js";
6
6
  */
7
7
  const DEFAULT_TABLE_CONFIG = {
8
8
  warmTtlDays: 30,
9
- minWarmTenantBytes: 5 * 1024 * 1024,
10
- minChunkBytes: 1 * 1024 * 1024,
11
- maxChunkBytes: 50 * 1024 * 1024,
9
+ minWarmTenantBytes: 5242880,
10
+ minChunkBytes: 1048576,
11
+ maxChunkBytes: 52428800,
12
12
  maxRowsPerCycle: 5e4,
13
13
  enabled: true
14
14
  };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Container-runtime primitives shared by every site that starts a container for
3
+ * KiCI: the orchestrator's container scaler backend, its bare-metal backend in
4
+ * container mode, and the AGENT's own job-container sandbox.
5
+ *
6
+ * They live in `@kici-dev/shared` rather than in the orchestrator because the
7
+ * agent needs the same runtime injection: when an ordinary agent nests a job
8
+ * container from a customer's image, that image is no more likely to ship Node
9
+ * than the one a scaler spawns. Two copies of "materialize the KiCI runtime"
10
+ * would be two chances to disagree on the parts that are easy to get wrong —
11
+ * the volume key, the root-owned copy, and the self-verification.
12
+ *
13
+ * They are separate composable functions rather than one `spawnAgentContainer`
14
+ * that owns the whole sequence, because the create/start step is genuinely
15
+ * caller-specific — network isolation, label sets, bind lists and log capture
16
+ * differ per caller — while the steps below are identical everywhere.
17
+ *
18
+ * `dockerode` is a TYPE-ONLY import here: every function takes an already-built
19
+ * client, so `@kici-dev/shared` does not depend on it at runtime and a consumer
20
+ * that never imports this module never pulls it in.
21
+ */
22
+ import { z } from 'zod';
23
+ import type Docker from 'dockerode';
24
+ /**
25
+ * When to pull an image.
26
+ *
27
+ * Defined here rather than in the orchestrator's scaler types because
28
+ * `pullImageIfMissing` lives here and both the orchestrator and the agent call
29
+ * it. The orchestrator's `scaler/types.ts` re-exports it, so the scaler config
30
+ * schema and every operator-facing value stay exactly as they were.
31
+ */
32
+ export declare const ImagePullPolicy: z.ZodEnum<{
33
+ Always: "Always";
34
+ IfNotPresent: "IfNotPresent";
35
+ Never: "Never";
36
+ }>;
37
+ export type ImagePullPolicy = z.infer<typeof ImagePullPolicy>;
38
+ /** Registry credentials in the shape a container runtime expects. */
39
+ export interface RegistryAuthconfig {
40
+ username: string;
41
+ password: string;
42
+ serveraddress: string;
43
+ }
44
+ export interface PullImageOptions {
45
+ docker: Docker;
46
+ image: string;
47
+ /** Absent means an anonymous pull. */
48
+ authconfig?: RegistryAuthconfig | undefined;
49
+ signal?: AbortSignal | undefined;
50
+ /** Progress sink; callers surface this as a scaler event. */
51
+ onProgress?: ((message: string) => void) | undefined;
52
+ /**
53
+ * When to pull. Defaults to `IfNotPresent`, which is right for KiCI's own
54
+ * agent images (pinned and immutable, so re-pulling every spawn only storms
55
+ * the registry). A label set on a moving tag sets `Always`.
56
+ */
57
+ pullPolicy?: ImagePullPolicy | undefined;
58
+ }
59
+ /**
60
+ * Pull `image` according to the pull policy. Returns whether a pull ran.
61
+ *
62
+ * Authenticated when an authconfig is supplied — a private registry otherwise
63
+ * fails the pull with a 401 that reads like a missing image.
64
+ */
65
+ export declare function pullImageIfMissing(opts: PullImageOptions): Promise<boolean>;
66
+ /** Read-only mount point of the KiCI-provisioned runtime inside a container. */
67
+ export declare const RUNTIME_MOUNT = "/opt/kici";
68
+ /** Read-only mount point of the injected Node tree inside a job container. */
69
+ export declare const RUNTIME_NODE_MOUNT = "/opt/kici/node";
70
+ /**
71
+ * Bind spec that injects the KiCI runtime into a container.
72
+ *
73
+ * Read-only is load-bearing: a job that could rewrite the runtime would control
74
+ * the interpreter every later step runs under.
75
+ */
76
+ export declare function runtimeInjectBind(hostRuntimeDir: string): string;
77
+ /**
78
+ * Command that starts the agent from the INJECTED runtime.
79
+ *
80
+ * Only meaningful when the runtime is mounted. A spawned job image has its own
81
+ * default CMD — python's shell, a node REPL, whatever the customer's image
82
+ * declares — so without overriding it the container starts that instead and no
83
+ * agent ever registers. The job then waits for an agent that will never arrive.
84
+ *
85
+ * Absolute on both halves: the image is not required to ship Node, and the
86
+ * agent lives inside the runtime tree rather than at the image's own /app.
87
+ */
88
+ export declare function injectedAgentCommand(): string[];
89
+ /**
90
+ * Which part of an agent image's `/opt/kici` tree a caller needs.
91
+ *
92
+ * - `all` — node PLUS the agent application. What a spawn that runs the AGENT
93
+ * ITSELF out of the volume needs (the per-job-image topologies), because the
94
+ * entrypoint resolves inside the mounted tree.
95
+ * - `node` — the Node tree alone, mounted at `/opt/kici/node`. What an agent
96
+ * nesting a job container needs: the runner bundle is bind-mounted from that
97
+ * agent's OWN build, so the runner and the agent driving it can never be two
98
+ * different versions.
99
+ *
100
+ * They are separate volumes rather than one tree mounted twice because the
101
+ * sandbox binds its runner and loader hook at fixed paths under `/opt/kici`.
102
+ * Mounting the whole tree read-only at `/opt/kici` would put those two binds
103
+ * INSIDE a read-only mount, whose mountpoints cannot be created.
104
+ */
105
+ export declare const RuntimeSubtree: z.ZodEnum<{
106
+ all: "all";
107
+ node: "node";
108
+ }>;
109
+ export type RuntimeSubtree = z.infer<typeof RuntimeSubtree>;
110
+ /**
111
+ * Name of the shared volume holding the KiCI runtime, keyed by image IDENTITY.
112
+ *
113
+ * Keyed by the image's content id, never its name:tag. A tag moves — `:stg`,
114
+ * `:latest`, and every E2E tag are rebuilt in place — so a name-keyed volume is
115
+ * reused after the image it was copied from has been replaced, which is exactly
116
+ * the "silently reusing the previous version's binaries" failure this is
117
+ * supposed to prevent. It bit: a volume populated from a pre-/opt/kici image was
118
+ * reused after the rebuild, and every spawned container failed to start because
119
+ * the runtime it mounted was empty.
120
+ *
121
+ * The subtree is part of the name for the same reason: an `all` volume and a
122
+ * `node` volume have different roots, so sharing one name would mount a tree
123
+ * whose `bin/node` is one level off.
124
+ */
125
+ export declare function runtimeVolumeName(imageId: string, subtree?: RuntimeSubtree): string;
126
+ export interface EnsureRuntimeVolumeOptions {
127
+ docker: Docker;
128
+ /** The published kici-agent image, which carries /opt/kici. */
129
+ agentImage: string;
130
+ /** Which part of the tree to materialize. Defaults to the whole runtime. */
131
+ subtree?: RuntimeSubtree | undefined;
132
+ signal?: AbortSignal | undefined;
133
+ onProgress?: ((message: string) => void) | undefined;
134
+ }
135
+ /**
136
+ * Materialize the KiCI runtime into a named volume that job containers mount.
137
+ *
138
+ * The runtime lives inside the `kici-agent` image, but a bind mount needs a
139
+ * HOST path — and the orchestrator (or the agent) may itself be containerized,
140
+ * so it cannot assume /opt/kici exists on the host filesystem. Copying the tree
141
+ * out of the image into a named volume once, then mounting that volume, works
142
+ * the same whether the caller runs on bare metal or in a container.
143
+ *
144
+ * Idempotent: the volume is created once per (agent image, subtree) and reused.
145
+ * Returns the volume name to mount.
146
+ */
147
+ export declare function ensureRuntimeVolume(opts: EnsureRuntimeVolumeOptions): Promise<string>;
148
+ //# sourceMappingURL=container-runtime.d.ts.map
@@ -0,0 +1,298 @@
1
+ import "./rolldown-runtime-ClRpJifh.js";
2
+ import { z } from "zod";
3
+ //#region src/container-runtime.ts
4
+ /**
5
+ * Container-runtime primitives shared by every site that starts a container for
6
+ * KiCI: the orchestrator's container scaler backend, its bare-metal backend in
7
+ * container mode, and the AGENT's own job-container sandbox.
8
+ *
9
+ * They live in `@kici-dev/shared` rather than in the orchestrator because the
10
+ * agent needs the same runtime injection: when an ordinary agent nests a job
11
+ * container from a customer's image, that image is no more likely to ship Node
12
+ * than the one a scaler spawns. Two copies of "materialize the KiCI runtime"
13
+ * would be two chances to disagree on the parts that are easy to get wrong —
14
+ * the volume key, the root-owned copy, and the self-verification.
15
+ *
16
+ * They are separate composable functions rather than one `spawnAgentContainer`
17
+ * that owns the whole sequence, because the create/start step is genuinely
18
+ * caller-specific — network isolation, label sets, bind lists and log capture
19
+ * differ per caller — while the steps below are identical everywhere.
20
+ *
21
+ * `dockerode` is a TYPE-ONLY import here: every function takes an already-built
22
+ * client, so `@kici-dev/shared` does not depend on it at runtime and a consumer
23
+ * that never imports this module never pulls it in.
24
+ */
25
+ /**
26
+ * When to pull an image.
27
+ *
28
+ * Defined here rather than in the orchestrator's scaler types because
29
+ * `pullImageIfMissing` lives here and both the orchestrator and the agent call
30
+ * it. The orchestrator's `scaler/types.ts` re-exports it, so the scaler config
31
+ * schema and every operator-facing value stay exactly as they were.
32
+ */
33
+ const ImagePullPolicy = z.enum([
34
+ "Always",
35
+ "IfNotPresent",
36
+ "Never"
37
+ ]);
38
+ /**
39
+ * Pull `image` according to the pull policy. Returns whether a pull ran.
40
+ *
41
+ * Authenticated when an authconfig is supplied — a private registry otherwise
42
+ * fails the pull with a 401 that reads like a missing image.
43
+ */
44
+ async function pullImageIfMissing(opts) {
45
+ const { docker, image, authconfig, signal } = opts;
46
+ const policy = opts.pullPolicy ?? ImagePullPolicy.enum.IfNotPresent;
47
+ if (policy === ImagePullPolicy.enum.Never) return false;
48
+ if (policy === ImagePullPolicy.enum.IfNotPresent) try {
49
+ await docker.getImage(image).inspect({ ...signal ? { abortSignal: signal } : {} });
50
+ return false;
51
+ } catch {}
52
+ if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("container spawn aborted");
53
+ opts.onProgress?.(`pulling image ${image}`);
54
+ const stream = await docker.pull(image, {
55
+ ...signal ? { abortSignal: signal } : {},
56
+ ...authconfig ? { authconfig } : {}
57
+ });
58
+ await new Promise((resolve, reject) => {
59
+ docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
60
+ });
61
+ return true;
62
+ }
63
+ /** Read-only mount point of the KiCI-provisioned runtime inside a container. */
64
+ const RUNTIME_MOUNT = "/opt/kici";
65
+ /** Read-only mount point of the injected Node tree inside a job container. */
66
+ const RUNTIME_NODE_MOUNT = `${RUNTIME_MOUNT}/node`;
67
+ /**
68
+ * Bind spec that injects the KiCI runtime into a container.
69
+ *
70
+ * Read-only is load-bearing: a job that could rewrite the runtime would control
71
+ * the interpreter every later step runs under.
72
+ */
73
+ function runtimeInjectBind(hostRuntimeDir) {
74
+ return `${hostRuntimeDir}:${RUNTIME_MOUNT}:ro`;
75
+ }
76
+ /**
77
+ * Command that starts the agent from the INJECTED runtime.
78
+ *
79
+ * Only meaningful when the runtime is mounted. A spawned job image has its own
80
+ * default CMD — python's shell, a node REPL, whatever the customer's image
81
+ * declares — so without overriding it the container starts that instead and no
82
+ * agent ever registers. The job then waits for an agent that will never arrive.
83
+ *
84
+ * Absolute on both halves: the image is not required to ship Node, and the
85
+ * agent lives inside the runtime tree rather than at the image's own /app.
86
+ */
87
+ function injectedAgentCommand() {
88
+ return [`${RUNTIME_MOUNT}/node/bin/node`, `${RUNTIME_MOUNT}/app/packages/agent/dist/server.js`];
89
+ }
90
+ /**
91
+ * Which part of an agent image's `/opt/kici` tree a caller needs.
92
+ *
93
+ * - `all` — node PLUS the agent application. What a spawn that runs the AGENT
94
+ * ITSELF out of the volume needs (the per-job-image topologies), because the
95
+ * entrypoint resolves inside the mounted tree.
96
+ * - `node` — the Node tree alone, mounted at `/opt/kici/node`. What an agent
97
+ * nesting a job container needs: the runner bundle is bind-mounted from that
98
+ * agent's OWN build, so the runner and the agent driving it can never be two
99
+ * different versions.
100
+ *
101
+ * They are separate volumes rather than one tree mounted twice because the
102
+ * sandbox binds its runner and loader hook at fixed paths under `/opt/kici`.
103
+ * Mounting the whole tree read-only at `/opt/kici` would put those two binds
104
+ * INSIDE a read-only mount, whose mountpoints cannot be created.
105
+ */
106
+ const RuntimeSubtree = z.enum(["all", "node"]);
107
+ /**
108
+ * Name of the shared volume holding the KiCI runtime, keyed by image IDENTITY.
109
+ *
110
+ * Keyed by the image's content id, never its name:tag. A tag moves — `:stg`,
111
+ * `:latest`, and every E2E tag are rebuilt in place — so a name-keyed volume is
112
+ * reused after the image it was copied from has been replaced, which is exactly
113
+ * the "silently reusing the previous version's binaries" failure this is
114
+ * supposed to prevent. It bit: a volume populated from a pre-/opt/kici image was
115
+ * reused after the rebuild, and every spawned container failed to start because
116
+ * the runtime it mounted was empty.
117
+ *
118
+ * The subtree is part of the name for the same reason: an `all` volume and a
119
+ * `node` volume have different roots, so sharing one name would mount a tree
120
+ * whose `bin/node` is one level off.
121
+ */
122
+ function runtimeVolumeName(imageId, subtree = RuntimeSubtree.enum.all) {
123
+ const slug = imageId.replace(/^sha256:/, "").replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 32);
124
+ return `kici-runtime-${subtree === RuntimeSubtree.enum.node ? "node-" : ""}${slug}`;
125
+ }
126
+ /** Where the populator and the marker probe mount the volume being filled. */
127
+ const RUNTIME_OUT = "/kici-runtime-out";
128
+ /**
129
+ * Marker file proving the volume holds a COMPLETE runtime.
130
+ *
131
+ * Volume existence is not population. A volume is created before the copy runs,
132
+ * so a concurrent caller's `inspect()` succeeds against a volume that is still
133
+ * being filled, and it mounts a half-copied tree — which surfaces far from the
134
+ * cause, typically as a zod ESM SyntaxError from a partially written module.
135
+ * The marker is written LAST, after the populator's own `test -x` checks, so it
136
+ * can only appear on a tree that already verified.
137
+ */
138
+ const RUNTIME_COMPLETE_MARKER = `${RUNTIME_OUT}/.kici-runtime-complete`;
139
+ /**
140
+ * The self-verifying copy each subtree runs in the populator container.
141
+ *
142
+ * The verification is not decoration. `cp -a` of an absent or empty source
143
+ * exits 0, leaving an EMPTY volume — and the spawn then creates a container
144
+ * whose runtime mount has no node, which dies at start with no error recorded
145
+ * anywhere near the cause.
146
+ *
147
+ * The completion marker is the LAST command in both branches: written any
148
+ * earlier it would land on a tree that has not been verified, which is the
149
+ * exact failure it exists to prevent.
150
+ */
151
+ function populatorCommand(subtree) {
152
+ const out = RUNTIME_OUT;
153
+ if (subtree === RuntimeSubtree.enum.node) return `set -e; cp -a ${RUNTIME_MOUNT}/node/. ${out}/; test -x ${out}/bin/node; touch ${RUNTIME_COMPLETE_MARKER}`;
154
+ return `set -e; cp -a ${RUNTIME_MOUNT}/. ${out}/; test -x ${out}/node/bin/node; test -f ${out}/app/packages/agent/dist/server.js; touch ${RUNTIME_COMPLETE_MARKER}`;
155
+ }
156
+ /** What the populator container printed, for an error that has to explain itself. */
157
+ async function populatorOutput(container) {
158
+ try {
159
+ const buf = await container.logs({
160
+ stdout: true,
161
+ stderr: true,
162
+ tail: 20
163
+ });
164
+ return Buffer.isBuffer(buf) ? buf.toString("utf-8").trim() : String(buf).trim();
165
+ } catch {
166
+ return "";
167
+ }
168
+ }
169
+ /**
170
+ * Materialize the KiCI runtime into a named volume that job containers mount.
171
+ *
172
+ * The runtime lives inside the `kici-agent` image, but a bind mount needs a
173
+ * HOST path — and the orchestrator (or the agent) may itself be containerized,
174
+ * so it cannot assume /opt/kici exists on the host filesystem. Copying the tree
175
+ * out of the image into a named volume once, then mounting that volume, works
176
+ * the same whether the caller runs on bare metal or in a container.
177
+ *
178
+ * Idempotent: the volume is created once per (agent image, subtree) and reused.
179
+ * Returns the volume name to mount.
180
+ */
181
+ async function ensureRuntimeVolume(opts) {
182
+ const { docker, agentImage, signal } = opts;
183
+ const subtree = opts.subtree ?? RuntimeSubtree.enum.all;
184
+ await pullImageIfMissing({
185
+ docker,
186
+ image: agentImage,
187
+ ...signal ? { signal } : {}
188
+ });
189
+ const name = runtimeVolumeName((await docker.getImage(agentImage).inspect()).Id ?? agentImage, subtree);
190
+ const inFlight = inFlightRuntimeVolumes.get(name);
191
+ if (inFlight) return await inFlight;
192
+ const populating = materializeRuntimeVolume({
193
+ ...opts,
194
+ subtree,
195
+ name
196
+ }).finally(() => {
197
+ inFlightRuntimeVolumes.delete(name);
198
+ });
199
+ inFlightRuntimeVolumes.set(name, populating);
200
+ return await populating;
201
+ }
202
+ /** Per-volume populate in flight right now, so concurrent callers share one. */
203
+ const inFlightRuntimeVolumes = /* @__PURE__ */ new Map();
204
+ /**
205
+ * Does this volume carry the completion marker?
206
+ *
207
+ * A short-lived container is the only way to read a named volume's contents:
208
+ * the daemon owns the mount, and the caller may itself be containerized, so
209
+ * there is no host path to stat. Mounted the same way the populator mounts it
210
+ * (root, same bind) so the probe sees exactly what the populator wrote.
211
+ *
212
+ * Any failure to answer reads as NOT populated. Repopulating a good volume
213
+ * costs one redundant copy; mounting an unverified one is the bug.
214
+ */
215
+ async function runtimeVolumeIsPopulated(args) {
216
+ const { docker, name, agentImage } = args;
217
+ let probe;
218
+ try {
219
+ probe = await docker.createContainer({
220
+ Image: agentImage,
221
+ User: "0:0",
222
+ Cmd: [
223
+ "sh",
224
+ "-c",
225
+ `test -f ${RUNTIME_COMPLETE_MARKER}`
226
+ ],
227
+ Labels: { "kici-managed": "true" },
228
+ HostConfig: {
229
+ Binds: [`${name}:${RUNTIME_OUT}`],
230
+ AutoRemove: false
231
+ }
232
+ });
233
+ await probe.start();
234
+ return (await probe.wait()).StatusCode === 0;
235
+ } catch {
236
+ return false;
237
+ } finally {
238
+ await probe?.remove({ force: true }).catch(() => void 0);
239
+ }
240
+ }
241
+ /** Populate the volume, reusing it only when the marker proves it is complete. */
242
+ async function materializeRuntimeVolume(opts) {
243
+ const { docker, agentImage, subtree, name } = opts;
244
+ const exists = await docker.getVolume(name).inspect().then(() => true).catch(() => false);
245
+ if (exists && await runtimeVolumeIsPopulated({
246
+ docker,
247
+ name,
248
+ agentImage
249
+ })) return name;
250
+ opts.onProgress?.(`materializing the KiCI runtime from ${agentImage}`);
251
+ await docker.createVolume({
252
+ Name: name,
253
+ Labels: { "kici-managed": "true" }
254
+ });
255
+ const populator = await docker.createContainer({
256
+ Image: agentImage,
257
+ User: "0:0",
258
+ Cmd: [
259
+ "sh",
260
+ "-c",
261
+ populatorCommand(subtree)
262
+ ],
263
+ Labels: { "kici-managed": "true" },
264
+ HostConfig: {
265
+ Binds: [`${name}:${RUNTIME_OUT}`],
266
+ AutoRemove: false
267
+ }
268
+ });
269
+ let populatorDropped = false;
270
+ const dropPopulator = async () => {
271
+ if (populatorDropped) return;
272
+ populatorDropped = true;
273
+ await populator.remove({ force: true }).catch(() => void 0);
274
+ };
275
+ try {
276
+ await populator.start();
277
+ const result = await populator.wait();
278
+ if (result.StatusCode !== 0) {
279
+ const output = await populatorOutput(populator);
280
+ throw new Error(`runtime copy exited ${result.StatusCode ?? "unknown"}` + (output ? `: ${output}` : " with no output"));
281
+ }
282
+ } catch (err) {
283
+ await dropPopulator();
284
+ if (!exists && !await runtimeVolumeIsPopulated({
285
+ docker,
286
+ name,
287
+ agentImage
288
+ })) await docker.getVolume(name).remove({ force: true }).catch(() => void 0);
289
+ throw new Error(`Failed to materialize the KiCI runtime from ${agentImage}: ${err instanceof Error ? err.message : String(err)}. The image must carry /opt/kici/node and /opt/kici/app.`);
290
+ } finally {
291
+ await dropPopulator();
292
+ }
293
+ return name;
294
+ }
295
+ //#endregion
296
+ export { ImagePullPolicy, RUNTIME_MOUNT, RUNTIME_NODE_MOUNT, RuntimeSubtree, ensureRuntimeVolume, injectedAgentCommand, pullImageIfMissing, runtimeInjectBind, runtimeVolumeName };
297
+
298
+ //# sourceMappingURL=container-runtime.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=container-runtime.test.d.ts.map