@molecule/api-code-sandbox-docker 1.0.1 → 1.1.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/index.d.ts CHANGED
@@ -26,7 +26,50 @@
26
26
  * container/volume size (it needs specific storage drivers — e.g. overlay2 on xfs
27
27
  * with `pquota` — and errors on the common overlay2/ext4 host), so this provider
28
28
  * enforces none. The core `resources.diskMB` is accepted but not applied here; cap
29
- * disk at the host / volume level instead.
29
+ * disk at the host / volume level instead. `setResources({ diskMB })` THROWS
30
+ * rather than accepting a ceiling it cannot enforce.
31
+ *
32
+ * **Templates are not `commit` alone, and that is not an optimization.**
33
+ * `commitTemplate` copies the caller-named `capturePaths` into a throwaway,
34
+ * volume-less container and commits THAT. A sandbox keeps the project on a
35
+ * volume, and a volume is not part of a container's writable layer — so
36
+ * committing a sandbox directly yields an image that builds, tags, and boots
37
+ * with an empty workspace, with no error anywhere. The throwaway container
38
+ * handles tenant-authored bytes and is committed into something other tenants
39
+ * boot, so it runs with `--network none`, `CapDrop: ALL` plus the three
40
+ * capabilities extraction needs, a memory cap and a process cap, and setuid/setgid
41
+ * bits are stripped from the extracted tree afterwards.
42
+ *
43
+ * Templates live in their OWN repository (`templateRepository`, default
44
+ * `molecule-sandbox-template`) rather than sharing the base image's, so nothing
45
+ * written to sweep templates can reach the base image. `publishTemplate` /
46
+ * `fetchTemplate` use a Docker registry and require `templateRegistry`; without
47
+ * it they THROW rather than returning "not found", because "there is no shared
48
+ * store" and "the shared store does not have it" are different answers.
49
+ *
50
+ * **`hibernate()`/`resume()` report which mechanism ran.** A CRIU checkpoint
51
+ * preserves the process tree; a stop does not, and whether CRIU is available is a
52
+ * property of the host (the daemon's experimental flag plus the `criu` package),
53
+ * discovered at runtime. A sandbox that runs its dev servers as detached exec
54
+ * processes comes back from a stop-style wake alive and serving nothing, so
55
+ * callers must branch on `processesPreserved` — never on `status`, which says
56
+ * `running` either way. The first response that shows CRIU is unavailable
57
+ * disables it for the rest of the process.
58
+ *
59
+ * **`find()` costs one request per match.** Docker's container LIST omits
60
+ * `State.StartedAt`, which is the only field distinguishing an interrupted
61
+ * creation (`startedAt: null` — holds storage forever, will never run) from an
62
+ * ordinary stopped sandbox, so each match is then inspected. Narrow with
63
+ * `labels`; an unfiltered query on a busy host inspects every container.
64
+ *
65
+ * **`capacity()` measures THIS host, so it reports nothing for a TCP daemon.**
66
+ * Over a `tcp://` endpoint the daemon writes to a different machine's storage,
67
+ * and returning this machine's numbers would be a confident wrong answer — so it
68
+ * returns empty `headroom` and says why. On a local socket it reports free bytes
69
+ * and free inodes on the daemon's `DockerRootDir` plus `MemAvailable`. Inodes are
70
+ * omitted (not reported as zero) on filesystems with dynamic inode allocation.
71
+ * `admits` is always `null`: Docker has no quota of its own to consult, so the
72
+ * floors and the refusal stay with the caller.
30
73
  *
31
74
  * @module
32
75
  */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAE1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAE1B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
package/dist/index.js CHANGED
@@ -26,7 +26,50 @@
26
26
  * container/volume size (it needs specific storage drivers — e.g. overlay2 on xfs
27
27
  * with `pquota` — and errors on the common overlay2/ext4 host), so this provider
28
28
  * enforces none. The core `resources.diskMB` is accepted but not applied here; cap
29
- * disk at the host / volume level instead.
29
+ * disk at the host / volume level instead. `setResources({ diskMB })` THROWS
30
+ * rather than accepting a ceiling it cannot enforce.
31
+ *
32
+ * **Templates are not `commit` alone, and that is not an optimization.**
33
+ * `commitTemplate` copies the caller-named `capturePaths` into a throwaway,
34
+ * volume-less container and commits THAT. A sandbox keeps the project on a
35
+ * volume, and a volume is not part of a container's writable layer — so
36
+ * committing a sandbox directly yields an image that builds, tags, and boots
37
+ * with an empty workspace, with no error anywhere. The throwaway container
38
+ * handles tenant-authored bytes and is committed into something other tenants
39
+ * boot, so it runs with `--network none`, `CapDrop: ALL` plus the three
40
+ * capabilities extraction needs, a memory cap and a process cap, and setuid/setgid
41
+ * bits are stripped from the extracted tree afterwards.
42
+ *
43
+ * Templates live in their OWN repository (`templateRepository`, default
44
+ * `molecule-sandbox-template`) rather than sharing the base image's, so nothing
45
+ * written to sweep templates can reach the base image. `publishTemplate` /
46
+ * `fetchTemplate` use a Docker registry and require `templateRegistry`; without
47
+ * it they THROW rather than returning "not found", because "there is no shared
48
+ * store" and "the shared store does not have it" are different answers.
49
+ *
50
+ * **`hibernate()`/`resume()` report which mechanism ran.** A CRIU checkpoint
51
+ * preserves the process tree; a stop does not, and whether CRIU is available is a
52
+ * property of the host (the daemon's experimental flag plus the `criu` package),
53
+ * discovered at runtime. A sandbox that runs its dev servers as detached exec
54
+ * processes comes back from a stop-style wake alive and serving nothing, so
55
+ * callers must branch on `processesPreserved` — never on `status`, which says
56
+ * `running` either way. The first response that shows CRIU is unavailable
57
+ * disables it for the rest of the process.
58
+ *
59
+ * **`find()` costs one request per match.** Docker's container LIST omits
60
+ * `State.StartedAt`, which is the only field distinguishing an interrupted
61
+ * creation (`startedAt: null` — holds storage forever, will never run) from an
62
+ * ordinary stopped sandbox, so each match is then inspected. Narrow with
63
+ * `labels`; an unfiltered query on a busy host inspects every container.
64
+ *
65
+ * **`capacity()` measures THIS host, so it reports nothing for a TCP daemon.**
66
+ * Over a `tcp://` endpoint the daemon writes to a different machine's storage,
67
+ * and returning this machine's numbers would be a confident wrong answer — so it
68
+ * returns empty `headroom` and says why. On a local socket it reports free bytes
69
+ * and free inodes on the daemon's `DockerRootDir` plus `MemAvailable`. Inodes are
70
+ * omitted (not reported as zero) on filesystems with dynamic inode allocation.
71
+ * `admits` is always `null`: Docker has no quota of its own to consult, so the
72
+ * floors and the refusal stay with the caller.
30
73
  *
31
74
  * @module
32
75
  */
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Reading back what already exists — descriptors, queries, volumes, and live
3
+ * resource changes.
4
+ *
5
+ * These are the operations a control plane needs when it is NOT using a sandbox:
6
+ * reconciling its own records against the daemon, finding what leaked, applying
7
+ * an entitlement to a sandbox it just adopted. molecule.dev wrote all of them
8
+ * directly against the Docker socket precisely because the provider interface
9
+ * stopped at "make me a sandbox and let me run things in it".
10
+ *
11
+ * Two rules run through the whole module.
12
+ *
13
+ * **An empty list means "looked, found none".** Every enumeration here lets its
14
+ * failure propagate rather than degrading to `[]`. The caller of a sweep DELETES
15
+ * what it is told about, so "I could not enumerate" and "there is nothing" must
16
+ * never arrive as the same value.
17
+ *
18
+ * **When in doubt, claim the resource is in use.** Attachment and usage decide
19
+ * whether something gets deleted, so an unreadable answer resolves to the safe
20
+ * side, loudly, rather than to the convenient one.
21
+ *
22
+ * @module
23
+ */
24
+ import type { ListVolumesOptions, SandboxDescriptor, SandboxQuery, SandboxResources, VolumeInfo } from '@molecule/api-code-sandbox';
25
+ import type { DockerRequest } from './request.js';
26
+ /** What the inspection capabilities need from the provider. */
27
+ export interface InspectContext {
28
+ request: DockerRequest;
29
+ /** Label namespace the provider stamps on the sandboxes it creates. */
30
+ labelPrefix: string;
31
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
32
+ debug?: (message: string, meta?: Record<string, unknown>) => void;
33
+ }
34
+ /** Shape of the fields this module reads from `GET /containers/{id}/json`. */
35
+ interface ContainerInspect {
36
+ Id: string;
37
+ Created?: string;
38
+ Image?: string;
39
+ State?: {
40
+ Running?: boolean;
41
+ StartedAt?: string;
42
+ };
43
+ Config?: {
44
+ Labels?: Record<string, string>;
45
+ Image?: string;
46
+ };
47
+ Mounts?: Array<{
48
+ Type?: string;
49
+ Name?: string;
50
+ }>;
51
+ NetworkSettings?: {
52
+ Ports?: Record<string, Array<{
53
+ HostIp?: string;
54
+ HostPort?: string;
55
+ }> | null> | null;
56
+ };
57
+ }
58
+ /**
59
+ * Turn a Docker inspect payload into a provider-agnostic descriptor.
60
+ *
61
+ * The status mapping is deliberately the SAME coarse one `get()` uses
62
+ * (running / stopped) rather than a finer one. Two views of the same sandbox
63
+ * that disagree about its state is a bug generator, and the case a finer mapping
64
+ * would have served — never-started wreckage — is carried honestly by
65
+ * `startedAt: null` instead.
66
+ *
67
+ * @param ctx - Inspection context.
68
+ * @param info - The daemon's inspect payload.
69
+ * @returns The descriptor.
70
+ */
71
+ export declare function toDescriptor(ctx: InspectContext, info: ContainerInspect): SandboxDescriptor;
72
+ /**
73
+ * Describe one sandbox without building a handle for it.
74
+ *
75
+ * @param ctx - Inspection context.
76
+ * @param id - The sandbox id.
77
+ * @returns The descriptor, or `null` when no such sandbox exists.
78
+ */
79
+ export declare function describeContainer(ctx: InspectContext, id: string): Promise<SandboxDescriptor | null>;
80
+ /**
81
+ * Find sandboxes by the labels the caller put on them.
82
+ *
83
+ * Two passes on purpose. The list endpoint answers cheaply but omits
84
+ * `State.StartedAt`, which is the one field that distinguishes wreckage from a
85
+ * sandbox that is merely stopped — so each match is then inspected. The label
86
+ * filter is what keeps that bounded; an unfiltered query over a busy host is
87
+ * one request per container and the caller should narrow it.
88
+ *
89
+ * A container that disappears between the two passes is skipped, not failed:
90
+ * that is a real answer (it is gone), and reconciliation loops race removals
91
+ * constantly.
92
+ *
93
+ * @param ctx - Inspection context.
94
+ * @param query - Narrowing by label, status and project.
95
+ * @returns Descriptors for every match.
96
+ */
97
+ export declare function findContainers(ctx: InspectContext, query?: SandboxQuery): Promise<SandboxDescriptor[]>;
98
+ /**
99
+ * Enumerate named volumes and whether anything still has them attached.
100
+ *
101
+ * Attachment comes from Docker's `dangling` filter, which means "not used by any
102
+ * container, including stopped ones" — exactly the question a reclaim sweep is
103
+ * asking. When that second query fails, every volume is reported as ATTACHED and
104
+ * a warning is logged: an unreadable answer must not authorize deletion, and a
105
+ * volume is the only copy of a user's work.
106
+ *
107
+ * @param ctx - Inspection context.
108
+ * @param options - Narrowing by name prefix and attachment.
109
+ * @returns Every matching volume.
110
+ */
111
+ export declare function listDockerVolumes(ctx: InspectContext, options?: ListVolumesOptions): Promise<VolumeInfo[]>;
112
+ /**
113
+ * Change a running sandbox's resource ceilings.
114
+ *
115
+ * `Memory` and `MemorySwap` are always sent together, and swap is always twice
116
+ * RAM. Docker treats them as one setting — updating `Memory` alone leaves the old
117
+ * swap ceiling, and this call is the LAST writer on the adoption path, so a swap
118
+ * value that is not recomputed here silently strips the headroom the creation
119
+ * path granted. That is not hypothetical; it happened to every claimed sandbox.
120
+ *
121
+ * `diskMB` throws. A provider that cannot enforce a ceiling must say so, because
122
+ * the caller's alternative — believing the quota is applied — is worse than a
123
+ * visible failure.
124
+ *
125
+ * @param ctx - Inspection context.
126
+ * @param containerId - The sandbox to update.
127
+ * @param resources - The ceilings to change; omitted fields keep their value.
128
+ */
129
+ export declare function updateContainerResources(ctx: InspectContext, containerId: string, resources: Partial<SandboxResources>): Promise<void>;
130
+ export {};
131
+ //# sourceMappingURL=inspect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../src/inspect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAElB,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,UAAU,EACX,MAAM,4BAA4B,CAAA;AAEnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEjD,+DAA+D;AAC/D,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,aAAa,CAAA;IACtB,uEAAuE;IACvE,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;AASD,8EAA8E;AAC9E,UAAU,gBAAgB;IACxB,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACjD,MAAM,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5D,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAChD,eAAe,CAAC,EAAE;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;YAAE,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG,IAAI,CAAC,GAAG,IAAI,CAAA;KACpF,CAAA;CACF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,IAAI,EAAE,gBAAgB,GAAG,iBAAiB,CA2B3F;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAYnC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,cAAc,EACnB,KAAK,CAAC,EAAE,YAAY,GACnB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAwB9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,cAAc,EACnB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,UAAU,EAAE,CAAC,CAkCvB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,OAAO,CAAC,gBAAgB,CAAC,GACnC,OAAO,CAAC,IAAI,CAAC,CAqBf"}
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Reading back what already exists — descriptors, queries, volumes, and live
3
+ * resource changes.
4
+ *
5
+ * These are the operations a control plane needs when it is NOT using a sandbox:
6
+ * reconciling its own records against the daemon, finding what leaked, applying
7
+ * an entitlement to a sandbox it just adopted. molecule.dev wrote all of them
8
+ * directly against the Docker socket precisely because the provider interface
9
+ * stopped at "make me a sandbox and let me run things in it".
10
+ *
11
+ * Two rules run through the whole module.
12
+ *
13
+ * **An empty list means "looked, found none".** Every enumeration here lets its
14
+ * failure propagate rather than degrading to `[]`. The caller of a sweep DELETES
15
+ * what it is told about, so "I could not enumerate" and "there is nothing" must
16
+ * never arrive as the same value.
17
+ *
18
+ * **When in doubt, claim the resource is in use.** Attachment and usage decide
19
+ * whether something gets deleted, so an unreadable answer resolves to the safe
20
+ * side, loudly, rather than to the convenient one.
21
+ *
22
+ * @module
23
+ */
24
+ /**
25
+ * Docker's zero time. `State.StartedAt` holds it for a container that has been
26
+ * created and never started — the shape of an interrupted creation, which owns
27
+ * storage forever and will never run.
28
+ */
29
+ const NEVER_STARTED = '0001-01-01T00:00:00Z';
30
+ /**
31
+ * Turn a Docker inspect payload into a provider-agnostic descriptor.
32
+ *
33
+ * The status mapping is deliberately the SAME coarse one `get()` uses
34
+ * (running / stopped) rather than a finer one. Two views of the same sandbox
35
+ * that disagree about its state is a bug generator, and the case a finer mapping
36
+ * would have served — never-started wreckage — is carried honestly by
37
+ * `startedAt: null` instead.
38
+ *
39
+ * @param ctx - Inspection context.
40
+ * @param info - The daemon's inspect payload.
41
+ * @returns The descriptor.
42
+ */
43
+ export function toDescriptor(ctx, info) {
44
+ const labels = info.Config?.Labels ?? {};
45
+ const startedAt = info.State?.StartedAt;
46
+ const volumeFromMount = (info.Mounts ?? []).find((mount) => mount.Type === 'volume')?.Name;
47
+ const ports = [];
48
+ for (const [spec, bindings] of Object.entries(info.NetworkSettings?.Ports ?? {})) {
49
+ const sandboxPort = Number.parseInt(spec.split('/')[0] ?? '', 10);
50
+ if (!Number.isInteger(sandboxPort))
51
+ continue;
52
+ for (const binding of bindings ?? []) {
53
+ const port = Number.parseInt(binding.HostPort ?? '', 10);
54
+ if (!Number.isInteger(port) || port <= 0)
55
+ continue;
56
+ ports.push({ sandboxPort, host: binding.HostIp || '127.0.0.1', port });
57
+ }
58
+ }
59
+ return {
60
+ id: info.Id,
61
+ projectId: labels[`${ctx.labelPrefix}.projectId`] ?? null,
62
+ status: info.State?.Running ? 'running' : 'stopped',
63
+ labels,
64
+ createdAt: info.Created ?? null,
65
+ startedAt: !startedAt || startedAt === NEVER_STARTED ? null : startedAt,
66
+ templateRef: info.Config?.Image ?? info.Image ?? null,
67
+ volumeName: labels[`${ctx.labelPrefix}.volumeName`] ?? volumeFromMount ?? null,
68
+ ports,
69
+ };
70
+ }
71
+ /**
72
+ * Describe one sandbox without building a handle for it.
73
+ *
74
+ * @param ctx - Inspection context.
75
+ * @param id - The sandbox id.
76
+ * @returns The descriptor, or `null` when no such sandbox exists.
77
+ */
78
+ export async function describeContainer(ctx, id) {
79
+ try {
80
+ const info = (await ctx.request(`/containers/${id}/json`));
81
+ return toDescriptor(ctx, info);
82
+ }
83
+ catch (error) {
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ // 404 is the answer "no such sandbox". Every other failure is a failure to
86
+ // look, and a caller that reads it as absence will happily "clean up" a
87
+ // sandbox that is alive and busy.
88
+ if (/\b404\b|no such container/i.test(message))
89
+ return null;
90
+ throw error;
91
+ }
92
+ }
93
+ /**
94
+ * Find sandboxes by the labels the caller put on them.
95
+ *
96
+ * Two passes on purpose. The list endpoint answers cheaply but omits
97
+ * `State.StartedAt`, which is the one field that distinguishes wreckage from a
98
+ * sandbox that is merely stopped — so each match is then inspected. The label
99
+ * filter is what keeps that bounded; an unfiltered query over a busy host is
100
+ * one request per container and the caller should narrow it.
101
+ *
102
+ * A container that disappears between the two passes is skipped, not failed:
103
+ * that is a real answer (it is gone), and reconciliation loops race removals
104
+ * constantly.
105
+ *
106
+ * @param ctx - Inspection context.
107
+ * @param query - Narrowing by label, status and project.
108
+ * @returns Descriptors for every match.
109
+ */
110
+ export async function findContainers(ctx, query) {
111
+ const labelFilters = Object.entries(query?.labels ?? {}).map(([key, value]) => `${key}=${value}`);
112
+ if (query?.projectId)
113
+ labelFilters.push(`${ctx.labelPrefix}.projectId=${query.projectId}`);
114
+ const path = labelFilters.length > 0
115
+ ? `/containers/json?all=true&filters=${encodeURIComponent(JSON.stringify({ label: labelFilters }))}`
116
+ : '/containers/json?all=true';
117
+ // Not wrapped: a failed listing must reach the caller. A recovery loop that
118
+ // reads "nothing to recover" from a broken query silently stops recovering.
119
+ const listed = (await ctx.request(path));
120
+ const descriptors = [];
121
+ for (const entry of Array.isArray(listed) ? listed : []) {
122
+ const descriptor = await describeContainer(ctx, entry.Id);
123
+ if (!descriptor) {
124
+ ctx.debug?.('Sandbox vanished between listing and inspection', { id: entry.Id });
125
+ continue;
126
+ }
127
+ if (query?.statuses && !query.statuses.includes(descriptor.status))
128
+ continue;
129
+ descriptors.push(descriptor);
130
+ }
131
+ return descriptors;
132
+ }
133
+ /**
134
+ * Enumerate named volumes and whether anything still has them attached.
135
+ *
136
+ * Attachment comes from Docker's `dangling` filter, which means "not used by any
137
+ * container, including stopped ones" — exactly the question a reclaim sweep is
138
+ * asking. When that second query fails, every volume is reported as ATTACHED and
139
+ * a warning is logged: an unreadable answer must not authorize deletion, and a
140
+ * volume is the only copy of a user's work.
141
+ *
142
+ * @param ctx - Inspection context.
143
+ * @param options - Narrowing by name prefix and attachment.
144
+ * @returns Every matching volume.
145
+ */
146
+ export async function listDockerVolumes(ctx, options) {
147
+ const listed = (await ctx.request('/volumes'));
148
+ let unattached = null;
149
+ try {
150
+ const dangling = (await ctx.request(`/volumes?filters=${encodeURIComponent(JSON.stringify({ dangling: ['true'] }))}`));
151
+ unattached = new Set((dangling.Volumes ?? []).map((volume) => volume.Name));
152
+ }
153
+ catch (error) {
154
+ ctx.warn?.('Could not determine volume attachment — reporting every volume as attached so nothing is reclaimed on a guess', { error });
155
+ }
156
+ const volumes = [];
157
+ for (const volume of listed.Volumes ?? []) {
158
+ if (options?.namePrefix && !volume.Name.startsWith(options.namePrefix))
159
+ continue;
160
+ const attached = unattached === null ? true : !unattached.has(volume.Name);
161
+ if (options?.attached !== undefined && options.attached !== attached)
162
+ continue;
163
+ const size = volume.UsageData?.Size;
164
+ volumes.push({
165
+ name: volume.Name,
166
+ attached,
167
+ createdAt: volume.CreatedAt ?? null,
168
+ // Docker reports -1 when it has not computed usage (that needs a
169
+ // system-wide scan), which is "unknown", not "empty".
170
+ sizeBytes: typeof size === 'number' && size >= 0 ? size : null,
171
+ });
172
+ }
173
+ return volumes;
174
+ }
175
+ /**
176
+ * Change a running sandbox's resource ceilings.
177
+ *
178
+ * `Memory` and `MemorySwap` are always sent together, and swap is always twice
179
+ * RAM. Docker treats them as one setting — updating `Memory` alone leaves the old
180
+ * swap ceiling, and this call is the LAST writer on the adoption path, so a swap
181
+ * value that is not recomputed here silently strips the headroom the creation
182
+ * path granted. That is not hypothetical; it happened to every claimed sandbox.
183
+ *
184
+ * `diskMB` throws. A provider that cannot enforce a ceiling must say so, because
185
+ * the caller's alternative — believing the quota is applied — is worse than a
186
+ * visible failure.
187
+ *
188
+ * @param ctx - Inspection context.
189
+ * @param containerId - The sandbox to update.
190
+ * @param resources - The ceilings to change; omitted fields keep their value.
191
+ */
192
+ export async function updateContainerResources(ctx, containerId, resources) {
193
+ if (resources.diskMB !== undefined) {
194
+ throw new Error('The Docker sandbox provider cannot cap a container or volume size portably ' +
195
+ '(it needs a specific storage driver, e.g. overlay2 on xfs with pquota, and ' +
196
+ 'errors on the common overlay2/ext4 host), so `diskMB` cannot be applied. ' +
197
+ 'Cap disk at the host or volume level instead.');
198
+ }
199
+ const update = {};
200
+ if (resources.cpu !== undefined)
201
+ update.NanoCPUs = Math.round(resources.cpu * 1e9);
202
+ if (resources.memoryMB !== undefined) {
203
+ const memoryBytes = Math.round(resources.memoryMB * 1024 * 1024);
204
+ update.Memory = memoryBytes;
205
+ update.MemorySwap = memoryBytes * 2;
206
+ }
207
+ if (resources.pidsLimit !== undefined)
208
+ update.PidsLimit = resources.pidsLimit;
209
+ if (Object.keys(update).length === 0)
210
+ return;
211
+ await ctx.request(`/containers/${containerId}/update`, 'POST', update);
212
+ }
@@ -8,6 +8,20 @@
8
8
  */
9
9
  import type { SandboxProvider } from '@molecule/api-code-sandbox';
10
10
  import type { DockerConfig } from './types.js';
11
+ /**
12
+ * Validate the ports a caller asked to publish, falling back to this provider's
13
+ * historical API + Vite pair when it named none.
14
+ *
15
+ * Rejects rather than filters: a caller that asked for a port it cannot have
16
+ * needs to hear so, because the alternative is a dev server running inside a
17
+ * sandbox that nothing on the outside can ever reach — which looks like a broken
18
+ * application, not a bad argument.
19
+ *
20
+ * @param ports - Ports inside the sandbox the caller wants reachable.
21
+ * @returns The ports to expose and bind.
22
+ * @throws {Error} When a port is not a valid TCP port number.
23
+ */
24
+ export declare function resolvePublishPorts(ports?: number[]): number[];
11
25
  /**
12
26
  * Retry a Docker API operation that failed with a TRANSIENT fault — a request
13
27
  * timeout or a connection-level reset, i.e. the daemon was momentarily overwhelmed,
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAOV,eAAe,EAEhB,MAAM,4BAA4B,CAAA;AAOnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AA8I9C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,EACxC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,EAAE;IACJ,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IACjC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,GAAG,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;KAAE,CAAA;CAC1D,GACA,OAAO,CAAC,CAAC,CAAC,CA2BZ;AAo3BD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,eAAe,CAErE"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,OAAO,KAAK,EAcV,eAAe,EAOhB,MAAM,4BAA4B,CAAA;AAoBnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AA6J9C;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAQ9D;AAkCD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,kBAAkB,CAAC,CAAC,EACxC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,EAAE;IACJ,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IACjC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAA;IACrC,GAAG,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;KAAE,CAAA;CAC1D,GACA,OAAO,CAAC,CAAC,CAAC,CA2BZ;AA21CD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,CAAC,EAAE,YAAY,GAAG,eAAe,CAErE"}