@molecule/api-code-sandbox-docker 1.0.1 → 1.0.2

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Sandbox templates on Docker — capture a sandbox's filesystem, boot new
3
+ * sandboxes from it, share it between hosts, and reclaim it on a budget.
4
+ *
5
+ * ## Why the capture is not just `commit`
6
+ *
7
+ * `POST /commit` snapshots a container's WRITABLE LAYER, and a mounted volume is
8
+ * not part of it. A sandbox keeps the project on a volume — that is the whole
9
+ * point of the volume — so committing a sandbox directly produces an image that
10
+ * builds, tags, boots, and contains no project. Everything succeeds; the template
11
+ * is empty. So the capture copies the caller-named paths into a throwaway
12
+ * container that has NO volume, and commits that instead.
13
+ *
14
+ * The paths are named by the CALLER (`capturePaths`). They are not discovered,
15
+ * because the two answers a discovery would produce — "this sandbox has no
16
+ * project files" and "I looked in the wrong place" — are indistinguishable, and
17
+ * the wrong one silently ships an empty template that later boots as a working
18
+ * sandbox with an empty workspace.
19
+ *
20
+ * ## The throwaway container is a tenant boundary
21
+ *
22
+ * It extracts a TENANT-AUTHORED archive and is then committed into a template
23
+ * that OTHER tenants boot. So it runs with no network, no capabilities beyond the
24
+ * three the extraction needs, a memory cap and a process cap — and setuid/setgid
25
+ * bits are stripped after extraction, so a tenant cannot bake a privilege
26
+ * escalation into an image somebody else boots. A previous unhardened version of
27
+ * this step ran on the default bridge, outside every firewall rule, holding
28
+ * Docker's full default capability set.
29
+ *
30
+ * @module
31
+ */
32
+ import type { CommitTemplateOptions, ListTemplatesOptions, SandboxTemplate } from '@molecule/api-code-sandbox';
33
+ import { type DockerDownload, type DockerRequest, type DockerUpload } from './request.js';
34
+ /** What the template capability needs from the provider. */
35
+ export interface TemplateContext {
36
+ request: DockerRequest;
37
+ download: DockerDownload;
38
+ upload: DockerUpload;
39
+ /** Image the throwaway capture container runs. It only needs a shell. */
40
+ baseImage: string;
41
+ /** Local Docker repository that holds templates. One tag per template id. */
42
+ repository: string;
43
+ /** Remote registry host for publish/fetch, or `''` when none is configured. */
44
+ registry: string;
45
+ /** Base64 `X-Registry-Auth` value for the remote registry. */
46
+ registryAuth: string;
47
+ /** Label namespace for the template's own metadata labels. */
48
+ labelPrefix: string;
49
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
50
+ debug?: (message: string, meta?: Record<string, unknown>) => void;
51
+ }
52
+ /**
53
+ * Validate a caller-supplied template id.
54
+ *
55
+ * REJECTS rather than sanitizes. Sanitizing would map two distinct ids onto one
56
+ * tag, and the second commit would silently overwrite the first — a project
57
+ * restoring its own template and receiving somebody else's filesystem.
58
+ *
59
+ * @param templateId - The caller's identifier.
60
+ * @throws {Error} When the id cannot be used as a Docker tag.
61
+ */
62
+ export declare function assertTemplateId(templateId: string): void;
63
+ /**
64
+ * Capture a sandbox's filesystem into a reusable template.
65
+ *
66
+ * @param ctx - Template context.
67
+ * @param options - What to capture and what to call it.
68
+ * @returns The template as it now exists.
69
+ */
70
+ export declare function commitTemplate(ctx: TemplateContext, options: CommitTemplateOptions): Promise<SandboxTemplate>;
71
+ /**
72
+ * Read one template by the caller's identifier.
73
+ *
74
+ * @param ctx - Template context.
75
+ * @param templateId - The caller's identifier.
76
+ * @returns The template, or `null` when no image carries that tag.
77
+ */
78
+ export declare function getTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>;
79
+ /**
80
+ * Enumerate templates so the caller can apply its retention policy.
81
+ *
82
+ * @param ctx - Template context.
83
+ * @param options - Narrowing by id prefix.
84
+ * @returns Every matching template.
85
+ */
86
+ export declare function listTemplates(ctx: TemplateContext, options?: ListTemplatesOptions): Promise<SandboxTemplate[]>;
87
+ /**
88
+ * Delete a template, refusing while anything still boots from it.
89
+ *
90
+ * @param ctx - Template context.
91
+ * @param templateId - The caller's identifier.
92
+ */
93
+ export declare function removeTemplate(ctx: TemplateContext, templateId: string): Promise<void>;
94
+ /**
95
+ * Copy a local template into the configured registry.
96
+ *
97
+ * @param ctx - Template context.
98
+ * @param templateId - The caller's identifier.
99
+ */
100
+ export declare function publishTemplate(ctx: TemplateContext, templateId: string): Promise<void>;
101
+ /**
102
+ * Pull a template from the configured registry onto this host.
103
+ *
104
+ * @param ctx - Template context.
105
+ * @param templateId - The caller's identifier.
106
+ * @returns The now-local template, or `null` when the registry does not have it.
107
+ */
108
+ export declare function fetchTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>;
109
+ //# sourceMappingURL=templates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EACV,qBAAqB,EACrB,oBAAoB,EACpB,eAAe,EAChB,MAAM,4BAA4B,CAAA;AAEnC,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,cAAc,CAAA;AAErB,4DAA4D;AAC5D,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,aAAa,CAAA;IACtB,QAAQ,EAAE,cAAc,CAAA;IACxB,MAAM,EAAE,YAAY,CAAA;IACpB,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAA;IACjB,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAA;IAClB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,CAAA;IAChB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAA;IACpB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IAChE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;CAClE;AAaD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CASzD;AAiID;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,eAAe,CAAC,CAoF1B;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,eAAe,EACpB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAiCjC;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,eAAe,EACpB,OAAO,CAAC,EAAE,oBAAoB,GAC7B,OAAO,CAAC,eAAe,EAAE,CAAC,CAmC5B;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc5F;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB7F;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,eAAe,EACpB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CA6BjC"}
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Sandbox templates on Docker — capture a sandbox's filesystem, boot new
3
+ * sandboxes from it, share it between hosts, and reclaim it on a budget.
4
+ *
5
+ * ## Why the capture is not just `commit`
6
+ *
7
+ * `POST /commit` snapshots a container's WRITABLE LAYER, and a mounted volume is
8
+ * not part of it. A sandbox keeps the project on a volume — that is the whole
9
+ * point of the volume — so committing a sandbox directly produces an image that
10
+ * builds, tags, boots, and contains no project. Everything succeeds; the template
11
+ * is empty. So the capture copies the caller-named paths into a throwaway
12
+ * container that has NO volume, and commits that instead.
13
+ *
14
+ * The paths are named by the CALLER (`capturePaths`). They are not discovered,
15
+ * because the two answers a discovery would produce — "this sandbox has no
16
+ * project files" and "I looked in the wrong place" — are indistinguishable, and
17
+ * the wrong one silently ships an empty template that later boots as a working
18
+ * sandbox with an empty workspace.
19
+ *
20
+ * ## The throwaway container is a tenant boundary
21
+ *
22
+ * It extracts a TENANT-AUTHORED archive and is then committed into a template
23
+ * that OTHER tenants boot. So it runs with no network, no capabilities beyond the
24
+ * three the extraction needs, a memory cap and a process cap — and setuid/setgid
25
+ * bits are stripped after extraction, so a tenant cannot bake a privilege
26
+ * escalation into an image somebody else boots. A previous unhardened version of
27
+ * this step ran on the default bridge, outside every firewall rule, holding
28
+ * Docker's full default capability set.
29
+ *
30
+ * @module
31
+ */
32
+ import { assertNoStreamError, } from './request.js';
33
+ /**
34
+ * Legal Docker tag component, which is also a legal container-name component.
35
+ * A template id becomes both, so it must satisfy the stricter of the two.
36
+ */
37
+ const VALID_TEMPLATE_ID = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,110}$/;
38
+ /** Timeouts sized for operations that move gigabytes rather than metadata. */
39
+ const COMMIT_TIMEOUT_MS = 300_000;
40
+ const TRANSFER_TIMEOUT_MS = 600_000;
41
+ const REGISTRY_TIMEOUT_MS = 300_000;
42
+ /**
43
+ * Validate a caller-supplied template id.
44
+ *
45
+ * REJECTS rather than sanitizes. Sanitizing would map two distinct ids onto one
46
+ * tag, and the second commit would silently overwrite the first — a project
47
+ * restoring its own template and receiving somebody else's filesystem.
48
+ *
49
+ * @param templateId - The caller's identifier.
50
+ * @throws {Error} When the id cannot be used as a Docker tag.
51
+ */
52
+ export function assertTemplateId(templateId) {
53
+ if (!VALID_TEMPLATE_ID.test(templateId)) {
54
+ throw new Error(`Invalid template id ${JSON.stringify(templateId.slice(0, 32))}: must match ` +
55
+ `${String(VALID_TEMPLATE_ID)} so it can be used verbatim as a Docker tag. ` +
56
+ 'Ids are rejected rather than sanitized, because sanitizing would let two ' +
57
+ 'distinct ids collide onto one template.');
58
+ }
59
+ }
60
+ /**
61
+ * Absolute, traversal-free path inside a sandbox. Interpolated into a `sh -c`
62
+ * argument and into a URL query, so both shapes are checked here.
63
+ *
64
+ * @param path - Candidate path.
65
+ * @throws {Error} When the path is not a safe absolute path.
66
+ */
67
+ function assertCapturePath(path) {
68
+ if (!/^\/[^\0'"`$\\\n]*$/.test(path) || path.includes('..')) {
69
+ throw new Error(`Invalid capture path ${JSON.stringify(path.slice(0, 64))}: must be absolute`);
70
+ }
71
+ }
72
+ /** The provider-native reference for a template id. */
73
+ function templateRef(ctx, templateId) {
74
+ return `${ctx.repository}:${templateId}`;
75
+ }
76
+ /** The remote reference for a template id, in the configured registry. */
77
+ function remoteRef(ctx, templateId) {
78
+ return `${ctx.registry}/${ctx.repository}:${templateId}`;
79
+ }
80
+ /**
81
+ * Require a configured shared store, naming the setting that turns it on.
82
+ *
83
+ * A Docker daemon can always talk to a registry, so the capability is present;
84
+ * what is missing is the address. Throwing distinguishes that from "this
85
+ * template is not in the shared store", which is what a `null` return would say.
86
+ *
87
+ * @param ctx - Template context.
88
+ * @throws {Error} When no registry is configured.
89
+ */
90
+ function assertRegistry(ctx) {
91
+ if (!ctx.registry) {
92
+ throw new Error('No shared template store is configured for the Docker sandbox provider. ' +
93
+ 'Set `templateRegistry` (or SANDBOX_TEMPLATE_REGISTRY) to a Docker registry ' +
94
+ 'host to publish and fetch templates across hosts.');
95
+ }
96
+ }
97
+ /** The parent directory an archive of `path` must be extracted into. */
98
+ function parentOf(path) {
99
+ const trimmed = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
100
+ const index = trimmed.lastIndexOf('/');
101
+ return index <= 0 ? '/' : trimmed.slice(0, index);
102
+ }
103
+ /**
104
+ * Run a command in a container and fail loudly if it did not run.
105
+ *
106
+ * Best-effort would be wrong here: every exec in the capture path is a
107
+ * correctness step (make the destination exist, strip setuid bits), and a
108
+ * capture that skipped one produces a template that looks fine.
109
+ *
110
+ * @param ctx - Template context.
111
+ * @param containerId - Container to run in.
112
+ * @param command - Shell command.
113
+ */
114
+ async function execInContainer(ctx, containerId, command) {
115
+ const created = (await ctx.request(`/containers/${containerId}/exec`, 'POST', {
116
+ Cmd: ['sh', '-c', command],
117
+ AttachStdout: true,
118
+ AttachStderr: true,
119
+ }));
120
+ await ctx.request(`/exec/${created.Id}/start`, 'POST', { Detach: false, Tty: false });
121
+ const inspected = (await ctx.request(`/exec/${created.Id}/json`));
122
+ if (inspected?.ExitCode !== 0) {
123
+ throw new Error(`Command failed inside capture container (exit ${String(inspected?.ExitCode)}): ${command}`);
124
+ }
125
+ }
126
+ /** Remove a container by id or name, tolerating one that is already gone. */
127
+ async function removeContainer(ctx, ref) {
128
+ await ctx.request(`/containers/${ref}?force=true`, 'DELETE').catch((error) => {
129
+ ctx.debug?.('Capture container removal failed (it may already be gone)', { ref, error });
130
+ });
131
+ }
132
+ /**
133
+ * Every image reference currently backing a container, running or not.
134
+ *
135
+ * A stopped container still owns its filesystem, so it counts. The fallback on
136
+ * failure is deliberately `null` — the caller turns that into `inUse: true`,
137
+ * because a wrong `false` here authorizes deleting the image a live sandbox
138
+ * boots from.
139
+ *
140
+ * @param ctx - Template context.
141
+ * @returns The set of in-use image refs and ids, or `null` when it could not be read.
142
+ */
143
+ async function inUseImageRefs(ctx) {
144
+ try {
145
+ const containers = (await ctx.request('/containers/json?all=true'));
146
+ const refs = new Set();
147
+ for (const container of Array.isArray(containers) ? containers : []) {
148
+ if (container.Image)
149
+ refs.add(container.Image);
150
+ if (container.ImageID)
151
+ refs.add(container.ImageID.replace(/^sha256:/, ''));
152
+ }
153
+ return refs;
154
+ }
155
+ catch (error) {
156
+ ctx.warn?.('Could not list containers to determine template usage — assuming in use', { error });
157
+ return null;
158
+ }
159
+ }
160
+ /** Whether `imageId`/`ref` appears in the in-use set. `null` means "assume yes". */
161
+ function isInUse(inUse, ref, imageId) {
162
+ if (inUse === null)
163
+ return true;
164
+ if (inUse.has(ref))
165
+ return true;
166
+ const bare = imageId.replace(/^sha256:/, '');
167
+ for (const used of inUse) {
168
+ if (used === bare || bare.startsWith(used) || used.startsWith(bare))
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ /**
174
+ * Capture a sandbox's filesystem into a reusable template.
175
+ *
176
+ * @param ctx - Template context.
177
+ * @param options - What to capture and what to call it.
178
+ * @returns The template as it now exists.
179
+ */
180
+ export async function commitTemplate(ctx, options) {
181
+ assertTemplateId(options.templateId);
182
+ const capturePaths = options.capturePaths ?? [];
183
+ for (const path of capturePaths)
184
+ assertCapturePath(path);
185
+ const ref = templateRef(ctx, options.templateId);
186
+ const captureName = `mol-capture-${options.templateId}`;
187
+ // A previous run that died before its cleanup leaves this name taken, and the
188
+ // create below would fail on the conflict rather than on anything real.
189
+ await removeContainer(ctx, captureName);
190
+ const created = (await ctx.request(`/containers/create?name=${captureName}`, 'POST', {
191
+ Image: ctx.baseImage,
192
+ Cmd: ['tail', '-f', '/dev/null'],
193
+ Labels: {
194
+ [`${ctx.labelPrefix}.template.id`]: options.templateId,
195
+ ...(options.label ? { [`${ctx.labelPrefix}.template.label`]: options.label } : {}),
196
+ },
197
+ HostConfig: {
198
+ // It is driven entirely over the Docker socket by this process. It needs
199
+ // no network, and having one puts it outside the sandbox firewall's
200
+ // subnet match while it handles tenant bytes.
201
+ NetworkMode: 'none',
202
+ AutoRemove: false,
203
+ RestartPolicy: { Name: '' },
204
+ CapDrop: ['ALL'],
205
+ CapAdd: ['CHOWN', 'SETGID', 'SETUID'],
206
+ SecurityOpt: ['no-new-privileges'],
207
+ Memory: 2 * 1024 * 1024 * 1024,
208
+ MemorySwap: 2 * 1024 * 1024 * 1024,
209
+ PidsLimit: 256,
210
+ },
211
+ }));
212
+ try {
213
+ await ctx.request(`/containers/${created.Id}/start`, 'POST');
214
+ for (const path of capturePaths) {
215
+ const parent = parentOf(path);
216
+ await execInContainer(ctx, created.Id, `mkdir -p '${parent}'`);
217
+ // Streamed straight through: a project tree is gigabytes, and buffering it
218
+ // in this process would trade a slow capture for a dead API.
219
+ const archive = await ctx.download(`/containers/${options.sandboxId}/archive?path=${encodeURIComponent(path)}`, TRANSFER_TIMEOUT_MS);
220
+ await ctx.upload(`/containers/${created.Id}/archive?path=${encodeURIComponent(parent)}`, archive, TRANSFER_TIMEOUT_MS);
221
+ // Strip setuid/setgid from the extracted tree. Docker's archive extraction
222
+ // honors the mode bits in the archive, and the archive came from a
223
+ // container the tenant controls — so without this a tenant can plant a
224
+ // setuid-root binary in an image OTHER tenants boot. Scoped by `-perm` so
225
+ // it touches only the handful of files that have those bits, not the whole
226
+ // tree.
227
+ await execInContainer(ctx, created.Id, `find '${path}' -type f -perm /6000 -exec chmod a-s {} + 2>/dev/null; exit 0`);
228
+ }
229
+ const params = new URLSearchParams({
230
+ container: created.Id,
231
+ repo: ctx.repository,
232
+ tag: options.templateId,
233
+ pause: 'false',
234
+ });
235
+ if (options.label)
236
+ params.set('comment', options.label);
237
+ await ctx.request(`/commit?${params.toString()}`, 'POST', undefined, COMMIT_TIMEOUT_MS);
238
+ }
239
+ finally {
240
+ await removeContainer(ctx, created.Id);
241
+ }
242
+ const template = await getTemplate(ctx, options.templateId);
243
+ if (!template) {
244
+ throw new Error(`Template "${options.templateId}" was committed as ${ref} but cannot be read back`);
245
+ }
246
+ return template;
247
+ }
248
+ /**
249
+ * Read one template by the caller's identifier.
250
+ *
251
+ * @param ctx - Template context.
252
+ * @param templateId - The caller's identifier.
253
+ * @returns The template, or `null` when no image carries that tag.
254
+ */
255
+ export async function getTemplate(ctx, templateId) {
256
+ assertTemplateId(templateId);
257
+ const ref = templateRef(ctx, templateId);
258
+ let image;
259
+ try {
260
+ image = (await ctx.request(`/images/${encodeURIComponent(ref)}/json`));
261
+ }
262
+ catch (error) {
263
+ const message = error instanceof Error ? error.message : String(error);
264
+ // A 404 is the answer "no such template". Anything else is a failure to
265
+ // look, and reporting it as absence would turn a transient daemon hiccup
266
+ // into a full cold rebuild on every boot.
267
+ if (/\b404\b|no such image/i.test(message))
268
+ return null;
269
+ throw error;
270
+ }
271
+ const inUse = await inUseImageRefs(ctx);
272
+ const labels = image.Config?.Labels ?? {};
273
+ return {
274
+ id: labels[`${ctx.labelPrefix}.template.id`] ?? templateId,
275
+ ref,
276
+ createdAt: image.Created ?? '',
277
+ sizeBytes: typeof image.Size === 'number' ? image.Size : null,
278
+ ...(labels[`${ctx.labelPrefix}.template.label`]
279
+ ? { label: labels[`${ctx.labelPrefix}.template.label`] }
280
+ : {}),
281
+ inUse: isInUse(inUse, ref, image.Id ?? ''),
282
+ };
283
+ }
284
+ /**
285
+ * Enumerate templates so the caller can apply its retention policy.
286
+ *
287
+ * @param ctx - Template context.
288
+ * @param options - Narrowing by id prefix.
289
+ * @returns Every matching template.
290
+ */
291
+ export async function listTemplates(ctx, options) {
292
+ const filters = encodeURIComponent(JSON.stringify({ reference: [`${ctx.repository}:*`] }));
293
+ // No try/catch: a failed listing must reach the caller. Returning `[]` would
294
+ // tell a caller whose next step is deletion that nothing is over budget, and
295
+ // it would believe it.
296
+ const images = (await ctx.request(`/images/json?filters=${filters}`));
297
+ const inUse = await inUseImageRefs(ctx);
298
+ const templates = [];
299
+ for (const image of Array.isArray(images) ? images : []) {
300
+ for (const tag of image.RepoTags ?? []) {
301
+ if (!tag.startsWith(`${ctx.repository}:`))
302
+ continue;
303
+ const labels = image.Labels ?? {};
304
+ const id = labels[`${ctx.labelPrefix}.template.id`] ?? tag.slice(ctx.repository.length + 1);
305
+ if (options?.idPrefix && !id.startsWith(options.idPrefix))
306
+ continue;
307
+ templates.push({
308
+ id,
309
+ ref: tag,
310
+ createdAt: typeof image.Created === 'number' ? new Date(image.Created * 1000).toISOString() : '',
311
+ sizeBytes: typeof image.Size === 'number' ? image.Size : null,
312
+ ...(labels[`${ctx.labelPrefix}.template.label`]
313
+ ? { label: labels[`${ctx.labelPrefix}.template.label`] }
314
+ : {}),
315
+ inUse: isInUse(inUse, tag, image.Id ?? ''),
316
+ });
317
+ }
318
+ }
319
+ return templates;
320
+ }
321
+ /**
322
+ * Delete a template, refusing while anything still boots from it.
323
+ *
324
+ * @param ctx - Template context.
325
+ * @param templateId - The caller's identifier.
326
+ */
327
+ export async function removeTemplate(ctx, templateId) {
328
+ const template = await getTemplate(ctx, templateId);
329
+ // Already gone is a success — callers reconcile, and reconciliation re-runs.
330
+ if (!template)
331
+ return;
332
+ if (template.inUse) {
333
+ throw new Error(`Refusing to remove template "${templateId}" (${template.ref}): a sandbox is still ` +
334
+ 'backed by it, and removing it would destroy that sandbox.');
335
+ }
336
+ // Deliberately no `force`: the daemon independently refuses to remove an image
337
+ // a container references, which is a second net behind the check above for the
338
+ // sandbox that was created in between.
339
+ await ctx.request(`/images/${encodeURIComponent(template.ref)}`, 'DELETE');
340
+ }
341
+ /**
342
+ * Copy a local template into the configured registry.
343
+ *
344
+ * @param ctx - Template context.
345
+ * @param templateId - The caller's identifier.
346
+ */
347
+ export async function publishTemplate(ctx, templateId) {
348
+ assertRegistry(ctx);
349
+ const template = await getTemplate(ctx, templateId);
350
+ if (!template)
351
+ throw new Error(`Cannot publish template "${templateId}": it does not exist locally`);
352
+ await ctx.request(`/images/${encodeURIComponent(template.ref)}/tag?repo=${encodeURIComponent(`${ctx.registry}/${ctx.repository}`)}&tag=${encodeURIComponent(templateId)}`, 'POST');
353
+ const pushed = await ctx.request(`/images/${encodeURIComponent(`${ctx.registry}/${ctx.repository}`)}/push?tag=${encodeURIComponent(templateId)}`, 'POST', undefined, REGISTRY_TIMEOUT_MS, { 'X-Registry-Auth': ctx.registryAuth });
354
+ assertNoStreamError(pushed, `push of ${remoteRef(ctx, templateId)}`);
355
+ }
356
+ /**
357
+ * Pull a template from the configured registry onto this host.
358
+ *
359
+ * @param ctx - Template context.
360
+ * @param templateId - The caller's identifier.
361
+ * @returns The now-local template, or `null` when the registry does not have it.
362
+ */
363
+ export async function fetchTemplate(ctx, templateId) {
364
+ assertRegistry(ctx);
365
+ const remote = `${ctx.registry}/${ctx.repository}`;
366
+ try {
367
+ const pulled = await ctx.request(`/images/create?fromImage=${encodeURIComponent(remote)}&tag=${encodeURIComponent(templateId)}`, 'POST', undefined, REGISTRY_TIMEOUT_MS, { 'X-Registry-Auth': ctx.registryAuth });
368
+ assertNoStreamError(pulled, `pull of ${remoteRef(ctx, templateId)}`);
369
+ }
370
+ catch (error) {
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ // "The registry does not have it" is an answer, and the caller's next move is
373
+ // to build the template. Anything else is a failure to look, and must not be
374
+ // reported as absence.
375
+ if (/manifest unknown|not found|\b404\b/i.test(message))
376
+ return null;
377
+ throw error;
378
+ }
379
+ await ctx.request(`/images/${encodeURIComponent(`${remote}:${templateId}`)}/tag?repo=${encodeURIComponent(ctx.repository)}&tag=${encodeURIComponent(templateId)}`, 'POST');
380
+ return getTemplate(ctx, templateId);
381
+ }
package/dist/types.d.ts CHANGED
@@ -42,6 +42,29 @@ export interface DockerConfig {
42
42
  labelPrefix?: string;
43
43
  /** Preview URL template. Use {port} placeholder. */
44
44
  previewUrlTemplate?: string;
45
+ /**
46
+ * Docker repository holding sandbox TEMPLATES — one tag per template id.
47
+ * Defaults to `molecule-sandbox-template` (or `SANDBOX_TEMPLATE_REPOSITORY`).
48
+ *
49
+ * Deliberately NOT the base image's repository. Sharing one repository between
50
+ * the base image and every template makes `docker images <repo>` and any
51
+ * cleanup written against it operate on both, and the only thing standing
52
+ * between a template sweep and the base image is a tag-prefix convention.
53
+ */
54
+ templateRepository?: string;
55
+ /**
56
+ * Registry host for sharing templates across hosts, e.g. `registry.example.com`
57
+ * (or `SANDBOX_TEMPLATE_REGISTRY`). When unset, `publishTemplate`/`fetchTemplate`
58
+ * throw with an actionable message: the daemon CAN talk to a registry, so the
59
+ * capability is present and only the address is missing.
60
+ */
61
+ templateRegistry?: string;
62
+ /**
63
+ * Base64-encoded Docker `X-Registry-Auth` value for {@link DockerConfig.templateRegistry}
64
+ * (or `SANDBOX_TEMPLATE_REGISTRY_AUTH`). Defaults to an empty credential set,
65
+ * which is what an anonymous or already-logged-in daemon needs.
66
+ */
67
+ templateRegistryAuth?: string;
45
68
  }
46
69
  /**
47
70
  * Container Info interface.
@@ -65,5 +88,11 @@ export interface ProcessEnv {
65
88
  DOCKER_HOST?: string;
66
89
  /** Docker network sandbox containers attach to (default `molecule-sandbox`, ICC-off). Overridden by `config.network`. [C1-1] */
67
90
  SANDBOX_DOCKER_NETWORK?: string;
91
+ /** Docker repository holding sandbox templates (default `molecule-sandbox-template`). Overridden by `config.templateRepository`. */
92
+ SANDBOX_TEMPLATE_REPOSITORY?: string;
93
+ /** Registry host for sharing templates across hosts. Overridden by `config.templateRegistry`. */
94
+ SANDBOX_TEMPLATE_REGISTRY?: string;
95
+ /** Base64 `X-Registry-Auth` value for `SANDBOX_TEMPLATE_REGISTRY`. Overridden by `config.templateRegistryAuth`. */
96
+ SANDBOX_TEMPLATE_REGISTRY_AUTH?: string;
68
97
  }
69
98
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sHAAsH;IACtH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0IAA0I;IAC1I,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sCAAsC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oDAAoD;IACpD,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAClD;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,kHAAkH;IAClH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,wJAAwJ;IACxJ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gIAAgI;IAChI,sBAAsB,CAAC,EAAE,MAAM,CAAA;CAChC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sHAAsH;IACtH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0IAA0I;IAC1I,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,sCAAsC;IACtC,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,oDAAoD;IACpD,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAClD;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,kHAAkH;IAClH,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,wJAAwJ;IACxJ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gIAAgI;IAChI,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,oIAAoI;IACpI,2BAA2B,CAAC,EAAE,MAAM,CAAA;IACpC,iGAAiG;IACjG,yBAAyB,CAAC,EAAE,MAAM,CAAA;IAClC,mHAAmH;IACnH,8BAA8B,CAAC,EAAE,MAAM,CAAA;CACxC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-docker",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Docker-based code sandbox container provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "license": "Apache-2.0",
31
31
  "devDependencies": {
32
32
  "@molecule/api-bond": "1.0.1",
33
- "@molecule/api-code-sandbox": "1.0.1",
33
+ "@molecule/api-code-sandbox": "1.0.2",
34
34
  "@types/node": "26.1.2",
35
35
  "typescript": "6.0.3",
36
36
  "vitest": "4.1.10"