@molecule/api-code-sandbox-flyio 1.0.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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The S3-compatible object store that holds Fly sandbox templates.
3
+ *
4
+ * Fly has no `docker commit`: it PULLS images from a registry and cannot produce
5
+ * one from a running Machine, and the Machines API has no file-transfer endpoint
6
+ * at all (its whole surface is enumerated in
7
+ * https://docs.machines.dev/openapi.json — `exec` is the only way in or out). So a
8
+ * template here is a tar archive in object storage, and the bytes move between the
9
+ * sandbox and that store DIRECTLY over presigned URLs; this module is only the
10
+ * control plane's half — presigning, metadata, enumeration and deletion.
11
+ *
12
+ * **Storage is bond configuration, never interface surface.** Nothing about
13
+ * buckets or endpoints appears in `@molecule/api-code-sandbox`. This mirrors the
14
+ * Docker bond's `templateRegistry`/`templateRegistryAuth` pattern: the capability
15
+ * belongs to the provider, the address belongs to the operator.
16
+ *
17
+ * On Fly the natural store is Tigris — "Tigris is S3-compatible, so any AWS SDK
18
+ * works — point it at `https://t3.storage.dev`"
19
+ * (https://fly.io/docs/tigris/) — and `fly storage create` exports
20
+ * `BUCKET_NAME`, `AWS_ENDPOINT_URL_S3`, `AWS_ACCESS_KEY_ID` and
21
+ * `AWS_SECRET_ACCESS_KEY`, which are read here as fallbacks so a Fly-provisioned
22
+ * bucket needs no extra configuration. Any S3-compatible endpoint works; the
23
+ * operations used are the six Tigris documents as supported
24
+ * (https://www.tigrisdata.com/docs/api/s3/): PutObject, GetObject, HeadObject,
25
+ * ListObjectsV2, DeleteObject and DeleteObjects.
26
+ *
27
+ * @module
28
+ */
29
+ import type { FlyioConfig } from './types.js';
30
+ /** Default key prefix for every object this bond writes. */
31
+ export declare const DEFAULT_TEMPLATE_PREFIX = "molecule-sandbox-templates";
32
+ /**
33
+ * Region sent when none is configured. Tigris (and most S3-compatible stores)
34
+ * accept `auto`; the AWS SDK requires *some* region to build a signature, so this
35
+ * is not optional even against a store that ignores it.
36
+ */
37
+ export declare const DEFAULT_TEMPLATE_REGION = "auto";
38
+ /**
39
+ * AWS's documented ceiling on a SigV4 presigned URL: "If you use the AWS CLI or
40
+ * AWS SDKs, the expiration time can be set as high as 7 days."
41
+ * (https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)
42
+ * A larger value is not rejected at signing time — it simply produces a URL S3
43
+ * refuses — so it is clamped here.
44
+ */
45
+ export declare const MAX_PRESIGN_EXPIRY_SECONDS: number;
46
+ /** One object as this bond needs to see it. */
47
+ export interface StoredObject {
48
+ /** Full key, including the configured prefix. */
49
+ key: string;
50
+ /** Size in bytes. */
51
+ size: number;
52
+ /** Last-modified timestamp, ISO 8601, or `null` when the store did not report one. */
53
+ lastModified: string | null;
54
+ }
55
+ /**
56
+ * The object-store operations the template capability needs.
57
+ *
58
+ * Declared as an interface so `templates.ts` stays transport-free and its tests
59
+ * can drive a fake store, the same way `exec.ts` takes an injected `RawExec`.
60
+ */
61
+ export interface ObjectStore {
62
+ /** Human-readable identification of the store, for error messages. */
63
+ readonly describe: string;
64
+ /** Bucket holding the templates. Used to render the opaque `SandboxTemplate.ref`. */
65
+ readonly bucket: string;
66
+ /** Key prefix every template object lives under, with no trailing slash. */
67
+ readonly prefix: string;
68
+ /**
69
+ * Every object under a prefix, following pagination to the end.
70
+ * THROWS on failure — a caller that deletes must never read a failed query as
71
+ * "nothing is there".
72
+ */
73
+ list(prefix: string): Promise<StoredObject[]>;
74
+ /** One object's metadata, or `null` when it does not exist. Throws on any other failure. */
75
+ head(key: string): Promise<StoredObject | null>;
76
+ /** An object's body as text, or `null` when it does not exist. Throws on any other failure. */
77
+ getText(key: string): Promise<string | null>;
78
+ /** Write a small text object. */
79
+ putText(key: string, body: string, contentType: string): Promise<void>;
80
+ /** Delete objects. Deleting a key that is not there is a success. */
81
+ remove(keys: string[]): Promise<void>;
82
+ /** A presigned URL a sandbox can `PUT` an archive to. */
83
+ presignPut(key: string, expiresInSeconds: number): Promise<string>;
84
+ /** A presigned URL a sandbox can `GET` an archive from. */
85
+ presignGet(key: string, expiresInSeconds: number): Promise<string>;
86
+ }
87
+ /** Resolved connection settings for the template object store. */
88
+ interface ResolvedStorage {
89
+ bucket: string;
90
+ endpoint: string | undefined;
91
+ region: string;
92
+ accessKeyId: string;
93
+ secretAccessKey: string;
94
+ sessionToken: string | undefined;
95
+ forcePathStyle: boolean;
96
+ prefix: string;
97
+ }
98
+ /**
99
+ * Resolves template-storage settings from config and the environment.
100
+ *
101
+ * Returns `null` when the store is not configured at all, which the template
102
+ * methods turn into an actionable error naming the settings. Partial
103
+ * configuration is treated as unconfigured for the same reason: half a
104
+ * connection cannot be used, and reporting it as a connection failure would
105
+ * point an operator at the network instead of at their settings.
106
+ * @param config - Fly provider configuration.
107
+ * @returns The resolved settings, or `null` when storage is not configured.
108
+ */
109
+ export declare function resolveTemplateStorage(config: FlyioConfig): ResolvedStorage | null;
110
+ /**
111
+ * Creates the object store used for templates, or `null` when it is not
112
+ * configured.
113
+ *
114
+ * @param config - Fly provider configuration; every field has an env fallback.
115
+ * @returns An {@link ObjectStore}, or `null` when no bucket/credentials are set.
116
+ */
117
+ export declare function createTemplateStore(config: FlyioConfig): ObjectStore | null;
118
+ export {};
119
+ //# sourceMappingURL=storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAYH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAE7C,4DAA4D;AAC5D,eAAO,MAAM,uBAAuB,+BAA+B,CAAA;AAEnE;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,SAAS,CAAA;AAE7C;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAmB,CAAA;AAE1D,+CAA+C;AAC/C,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,GAAG,EAAE,MAAM,CAAA;IACX,qBAAqB;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,sFAAsF;IACtF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,qFAAqF;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB;;;;OAIG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IAC7C,4FAA4F;IAC5F,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAA;IAC/C,+FAA+F;IAC/F,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC5C,iCAAiC;IACjC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtE,qEAAqE;IACrE,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrC,yDAAyD;IACzD,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAClE,2DAA2D;IAC3D,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACnE;AAED,kEAAkE;AAClE,UAAU,eAAe;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAA;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,eAAe,EAAE,MAAM,CAAA;IACvB,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,cAAc,EAAE,OAAO,CAAA;IACvB,MAAM,EAAE,MAAM,CAAA;CACf;AAmBD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG,eAAe,GAAG,IAAI,CAqClF;AAmBD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,CA6H3E"}
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The S3-compatible object store that holds Fly sandbox templates.
3
+ *
4
+ * Fly has no `docker commit`: it PULLS images from a registry and cannot produce
5
+ * one from a running Machine, and the Machines API has no file-transfer endpoint
6
+ * at all (its whole surface is enumerated in
7
+ * https://docs.machines.dev/openapi.json — `exec` is the only way in or out). So a
8
+ * template here is a tar archive in object storage, and the bytes move between the
9
+ * sandbox and that store DIRECTLY over presigned URLs; this module is only the
10
+ * control plane's half — presigning, metadata, enumeration and deletion.
11
+ *
12
+ * **Storage is bond configuration, never interface surface.** Nothing about
13
+ * buckets or endpoints appears in `@molecule/api-code-sandbox`. This mirrors the
14
+ * Docker bond's `templateRegistry`/`templateRegistryAuth` pattern: the capability
15
+ * belongs to the provider, the address belongs to the operator.
16
+ *
17
+ * On Fly the natural store is Tigris — "Tigris is S3-compatible, so any AWS SDK
18
+ * works — point it at `https://t3.storage.dev`"
19
+ * (https://fly.io/docs/tigris/) — and `fly storage create` exports
20
+ * `BUCKET_NAME`, `AWS_ENDPOINT_URL_S3`, `AWS_ACCESS_KEY_ID` and
21
+ * `AWS_SECRET_ACCESS_KEY`, which are read here as fallbacks so a Fly-provisioned
22
+ * bucket needs no extra configuration. Any S3-compatible endpoint works; the
23
+ * operations used are the six Tigris documents as supported
24
+ * (https://www.tigrisdata.com/docs/api/s3/): PutObject, GetObject, HeadObject,
25
+ * ListObjectsV2, DeleteObject and DeleteObjects.
26
+ *
27
+ * @module
28
+ */
29
+ import { DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
30
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
31
+ /** Default key prefix for every object this bond writes. */
32
+ export const DEFAULT_TEMPLATE_PREFIX = 'molecule-sandbox-templates';
33
+ /**
34
+ * Region sent when none is configured. Tigris (and most S3-compatible stores)
35
+ * accept `auto`; the AWS SDK requires *some* region to build a signature, so this
36
+ * is not optional even against a store that ignores it.
37
+ */
38
+ export const DEFAULT_TEMPLATE_REGION = 'auto';
39
+ /**
40
+ * AWS's documented ceiling on a SigV4 presigned URL: "If you use the AWS CLI or
41
+ * AWS SDKs, the expiration time can be set as high as 7 days."
42
+ * (https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)
43
+ * A larger value is not rejected at signing time — it simply produces a URL S3
44
+ * refuses — so it is clamped here.
45
+ */
46
+ export const MAX_PRESIGN_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
47
+ /**
48
+ * Reads one template-storage setting, preferring explicit config over the
49
+ * environment.
50
+ * @param configured - The value from {@link FlyioConfig}, if any.
51
+ * @param names - Environment variable names to try, in order.
52
+ * @returns The first non-empty value, or `undefined`.
53
+ */
54
+ function resolve(configured, names) {
55
+ const explicit = configured?.trim();
56
+ if (explicit)
57
+ return explicit;
58
+ for (const name of names) {
59
+ const value = process.env[name]?.trim();
60
+ if (value)
61
+ return value;
62
+ }
63
+ return undefined;
64
+ }
65
+ /**
66
+ * Resolves template-storage settings from config and the environment.
67
+ *
68
+ * Returns `null` when the store is not configured at all, which the template
69
+ * methods turn into an actionable error naming the settings. Partial
70
+ * configuration is treated as unconfigured for the same reason: half a
71
+ * connection cannot be used, and reporting it as a connection failure would
72
+ * point an operator at the network instead of at their settings.
73
+ * @param config - Fly provider configuration.
74
+ * @returns The resolved settings, or `null` when storage is not configured.
75
+ */
76
+ export function resolveTemplateStorage(config) {
77
+ const bucket = resolve(config.templateBucket, ['SANDBOX_TEMPLATE_BUCKET', 'BUCKET_NAME']);
78
+ const accessKeyId = resolve(config.templateAccessKeyId, [
79
+ 'SANDBOX_TEMPLATE_ACCESS_KEY_ID',
80
+ 'AWS_ACCESS_KEY_ID',
81
+ ]);
82
+ const secretAccessKey = resolve(config.templateSecretAccessKey, [
83
+ 'SANDBOX_TEMPLATE_SECRET_ACCESS_KEY',
84
+ 'AWS_SECRET_ACCESS_KEY',
85
+ ]);
86
+ if (!bucket || !accessKeyId || !secretAccessKey)
87
+ return null;
88
+ const forcePathStyleRaw = resolve(config.templateForcePathStyle === undefined ? undefined : String(config.templateForcePathStyle), ['SANDBOX_TEMPLATE_FORCE_PATH_STYLE']);
89
+ return {
90
+ bucket,
91
+ endpoint: resolve(config.templateEndpoint, [
92
+ 'SANDBOX_TEMPLATE_ENDPOINT',
93
+ 'AWS_ENDPOINT_URL_S3',
94
+ ]),
95
+ region: resolve(config.templateRegion, ['SANDBOX_TEMPLATE_REGION', 'AWS_REGION']) ??
96
+ DEFAULT_TEMPLATE_REGION,
97
+ accessKeyId,
98
+ secretAccessKey,
99
+ sessionToken: resolve(config.templateSessionToken, [
100
+ 'SANDBOX_TEMPLATE_SESSION_TOKEN',
101
+ 'AWS_SESSION_TOKEN',
102
+ ]),
103
+ forcePathStyle: forcePathStyleRaw === 'true',
104
+ prefix: (resolve(config.templatePrefix, ['SANDBOX_TEMPLATE_PREFIX']) ?? DEFAULT_TEMPLATE_PREFIX).replace(/\/+$/, ''),
105
+ };
106
+ }
107
+ /**
108
+ * Reports whether an S3 error means "no such object" rather than "the lookup
109
+ * failed".
110
+ *
111
+ * The distinction is the whole contract for `getTemplate`: absence is an answer
112
+ * the caller acts on by rebuilding, while a failed lookup reported as absence
113
+ * turns a transient store hiccup into a full cold rebuild on every boot.
114
+ * @param error - The thrown error.
115
+ * @returns `true` for a 404 / `NoSuchKey` / `NotFound`.
116
+ */
117
+ function isNotFound(error) {
118
+ const candidate = error;
119
+ if (candidate?.$metadata?.httpStatusCode === 404)
120
+ return true;
121
+ return candidate?.name === 'NotFound' || candidate?.name === 'NoSuchKey';
122
+ }
123
+ /**
124
+ * Creates the object store used for templates, or `null` when it is not
125
+ * configured.
126
+ *
127
+ * @param config - Fly provider configuration; every field has an env fallback.
128
+ * @returns An {@link ObjectStore}, or `null` when no bucket/credentials are set.
129
+ */
130
+ export function createTemplateStore(config) {
131
+ const settings = resolveTemplateStorage(config);
132
+ if (!settings)
133
+ return null;
134
+ const client = new S3Client({
135
+ region: settings.region,
136
+ ...(settings.endpoint ? { endpoint: settings.endpoint } : {}),
137
+ forcePathStyle: settings.forcePathStyle,
138
+ credentials: {
139
+ accessKeyId: settings.accessKeyId,
140
+ secretAccessKey: settings.secretAccessKey,
141
+ ...(settings.sessionToken ? { sessionToken: settings.sessionToken } : {}),
142
+ },
143
+ // The SDK's default is `WHEN_SUPPORTED`, which adds `x-amz-checksum-crc32`
144
+ // to every upload. On a PRESIGNED PUT that header becomes part of
145
+ // `X-Amz-SignedHeaders`, so the sandbox's `curl` would have to reproduce it
146
+ // exactly or the signature fails — and S3-compatible stores vary in whether
147
+ // they accept it at all. `WHEN_REQUIRED` is the documented opt-out:
148
+ // https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html
149
+ requestChecksumCalculation: 'WHEN_REQUIRED',
150
+ });
151
+ const expiry = (seconds) => Math.max(1, Math.min(Math.floor(seconds), MAX_PRESIGN_EXPIRY_SECONDS));
152
+ return {
153
+ describe: `s3://${settings.bucket}/${settings.prefix} at ${settings.endpoint ?? `region ${settings.region}`}`,
154
+ bucket: settings.bucket,
155
+ prefix: settings.prefix,
156
+ async list(prefix) {
157
+ const objects = [];
158
+ let token;
159
+ do {
160
+ const page = await client.send(new ListObjectsV2Command({
161
+ Bucket: settings.bucket,
162
+ Prefix: prefix,
163
+ ...(token ? { ContinuationToken: token } : {}),
164
+ }));
165
+ for (const entry of page.Contents ?? []) {
166
+ if (!entry.Key)
167
+ continue;
168
+ objects.push({
169
+ key: entry.Key,
170
+ size: typeof entry.Size === 'number' ? entry.Size : 0,
171
+ lastModified: entry.LastModified ? entry.LastModified.toISOString() : null,
172
+ });
173
+ }
174
+ // `IsTruncated` alone is not enough: a truncated page without a token
175
+ // would loop forever on the same page.
176
+ token = page.IsTruncated ? page.NextContinuationToken : undefined;
177
+ } while (token);
178
+ return objects;
179
+ },
180
+ async head(key) {
181
+ try {
182
+ const response = await client.send(new HeadObjectCommand({ Bucket: settings.bucket, Key: key }));
183
+ return {
184
+ key,
185
+ size: typeof response.ContentLength === 'number' ? response.ContentLength : 0,
186
+ lastModified: response.LastModified ? response.LastModified.toISOString() : null,
187
+ };
188
+ }
189
+ catch (error) {
190
+ if (isNotFound(error))
191
+ return null;
192
+ throw error;
193
+ }
194
+ },
195
+ async getText(key) {
196
+ try {
197
+ const response = await client.send(new GetObjectCommand({ Bucket: settings.bucket, Key: key }));
198
+ return (await response.Body?.transformToString()) ?? '';
199
+ }
200
+ catch (error) {
201
+ if (isNotFound(error))
202
+ return null;
203
+ throw error;
204
+ }
205
+ },
206
+ async putText(key, body, contentType) {
207
+ await client.send(new PutObjectCommand({
208
+ Bucket: settings.bucket,
209
+ Key: key,
210
+ Body: body,
211
+ ContentType: contentType,
212
+ }));
213
+ },
214
+ async remove(keys) {
215
+ // DeleteObjects caps at 1000 keys per request
216
+ // (https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html).
217
+ for (let index = 0; index < keys.length; index += 1000) {
218
+ const batch = keys.slice(index, index + 1000);
219
+ if (batch.length === 0)
220
+ continue;
221
+ await client.send(new DeleteObjectsCommand({
222
+ Bucket: settings.bucket,
223
+ Delete: { Objects: batch.map((key) => ({ Key: key })), Quiet: true },
224
+ }));
225
+ }
226
+ },
227
+ async presignPut(key, expiresInSeconds) {
228
+ // No ContentType and no metadata: anything set here joins
229
+ // `X-Amz-SignedHeaders` and must then be reproduced byte-for-byte by the
230
+ // sandbox's `curl`. Only `host` is signed, so the upload is a plain PUT.
231
+ return getSignedUrl(client, new PutObjectCommand({ Bucket: settings.bucket, Key: key }), {
232
+ expiresIn: expiry(expiresInSeconds),
233
+ });
234
+ },
235
+ async presignGet(key, expiresInSeconds) {
236
+ return getSignedUrl(client, new GetObjectCommand({ Bucket: settings.bucket, Key: key }), {
237
+ expiresIn: expiry(expiresInSeconds),
238
+ });
239
+ },
240
+ };
241
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Warm-start templates on Fly — capture a prepared sandbox filesystem once, and
3
+ * restore it into every later sandbox that wants the same configuration.
4
+ *
5
+ * ## Why a tar in object storage, and not something Fly-native
6
+ *
7
+ * The Docker bond's template is `docker commit`. Fly has no equivalent, and the
8
+ * two Fly-native mechanisms that look like one do not survive contact with this
9
+ * bond's isolation model:
10
+ *
11
+ * - **An OCI image.** Fly PULLS images from a registry; nothing in the Machines
12
+ * API produces an image from a running Machine. Building one would need the
13
+ * Machine's bytes in hand, which is the very thing that is hard here.
14
+ * - **A volume snapshot.** Fly can snapshot a volume and create a new volume from
15
+ * it (`POST /v1/apps/{app}/volumes` takes `snapshot_id` "restore from snapshot"
16
+ * and `source_volume_id` "fork from remote volume" —
17
+ * https://docs.machines.dev/openapi.json). But a warm-start template exists to
18
+ * be shared by every project with the same dependency set, and Fly documents
19
+ * that "Every Fly Volume belongs to a Fly App and you can't share a volume
20
+ * between apps" (https://fly.io/docs/volumes/overview/). This bond puts each
21
+ * project in its OWN app precisely for tenant isolation, so a snapshot taken in
22
+ * one project's app can never become another project's volume. Volume snapshots
23
+ * could serve a per-project restore point and nothing else, and the core
24
+ * deliberately has ONE template primitive rather than two — a provider that
25
+ * implemented only the per-project half would satisfy the type and silently
26
+ * fail the case the capability exists for.
27
+ *
28
+ * What is left is the boring one: `tar` the prepared paths into an S3-compatible
29
+ * object store and untar them into the new sandbox. The Machines API has no file
30
+ * transfer endpoint (`exec` is the entire surface), and pushing hundreds of
31
+ * megabytes through `exec`'s JSON response is not a slower version of a transfer,
32
+ * it is a different order of magnitude — so the bytes move DIRECTLY between the
33
+ * sandbox and the store over presigned URLs. The control plane only ever handles
34
+ * the URL, the manifest and the metadata.
35
+ *
36
+ * ## What the base image already gives you, and what this adds
37
+ *
38
+ * The base image (`registry.fly.io/molecule-sandbox:latest`) already carries the
39
+ * toolchain and a warmed npm cache, so this is NOT about installing Node. It is
40
+ * about the per-configuration layer — the scaffold plus its `node_modules` — which
41
+ * otherwise gets rebuilt on every single boot.
42
+ *
43
+ * ## The tenant boundary is the RESTORE, not the capture
44
+ *
45
+ * A capture runs inside a sandbox the tenant controls, so nothing done there is a
46
+ * security control: a tenant can replace `tar` and emit any archive it likes. The
47
+ * boundary is the restore, which runs in a fresh Machine before anyone gets a
48
+ * handle to it. Four independent properties are enforced there, each verified
49
+ * against GNU tar 1.35 rather than assumed:
50
+ *
51
+ * 1. **Only the manifest's `capturePaths` are extracted.** They are passed to
52
+ * `tar` as member selectors, and the manifest is written by the CONTROL PLANE,
53
+ * never by the sandbox. An archive that also contains `usr/bin/node` does not
54
+ * get it extracted.
55
+ * 2. **`..` and absolute members cannot escape.** GNU tar strips a leading `/`
56
+ * and refuses a member containing `..` ("Member name contains '..'") with a
57
+ * non-zero exit, which fails the restore.
58
+ * 3. **A symlink cannot be used to write outside the tree.** GNU tar refuses to
59
+ * follow a symlink member when extracting through it, and exits non-zero.
60
+ * 4. **setuid/setgid never survives, and that is ASSERTED.** Extraction uses
61
+ * `--no-same-owner --no-same-permissions`, a `find -perm /6000 -exec chmod a-s`
62
+ * sweep follows, and then a second `find` re-checks: any remaining setuid or
63
+ * setgid file FAILS the restore. A failed restore destroys the Machine rather
64
+ * than handing back a sandbox that looks healthy.
65
+ *
66
+ * What that does NOT cover, stated plainly: the presigned `PUT` handed to a
67
+ * capturing sandbox stays valid until it expires, so a hostile tenant can
68
+ * re-upload different bytes after the control plane has verified the object, and
69
+ * the manifest's `sizeBytes` would then be stale. It gains that tenant nothing it
70
+ * did not already have — it authored the archive in the first place — which is
71
+ * exactly why the boundary is on the restore side rather than on the contents.
72
+ * Shorten `templateUrlExpirySeconds` if a stale size would matter to you.
73
+ *
74
+ * ## What the sandbox image must contain
75
+ *
76
+ * The capture and restore run as shell commands inside the Machine, so the image
77
+ * needs `tar` (GNU: the `..`/symlink refusals and `--no-same-permissions` above
78
+ * are GNU tar behaviours), `gzip`, GNU `find` (for `-perm /6000`), `mktemp` that
79
+ * accepts a suffix after the `X`s, and `curl` new enough for `--fail-with-body`
80
+ * (7.76+). The stock `molecule-sandbox` image is Debian-based and has all of
81
+ * them; a custom `baseImage` built on BusyBox does not, and the failure surfaces
82
+ * as a capture that exits non-zero rather than as a wrong template.
83
+ *
84
+ * @module
85
+ */
86
+ import type { CommitTemplateOptions, ExecResult, ListTemplatesOptions, SandboxTemplate } from '@molecule/api-code-sandbox';
87
+ import type { ObjectStore, StoredObject } from './storage.js';
88
+ /**
89
+ * Single-`PUT` ceiling: "With a single `PUT` operation, you can upload a single
90
+ * object up to 5 GB in size"
91
+ * (https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html).
92
+ * The sandbox uploads with one presigned PUT, so an archive above this cannot be
93
+ * stored and the capture refuses before spending the bandwidth.
94
+ */
95
+ export declare const MAX_ARCHIVE_BYTES: number;
96
+ /** Default wall-clock budget for a capture or restore transfer, in ms. */
97
+ export declare const DEFAULT_TRANSFER_TIMEOUT_MS = 900000;
98
+ /** Default lifetime of a presigned capture/restore URL, in seconds. */
99
+ export declare const DEFAULT_PRESIGN_EXPIRY_SECONDS = 3600;
100
+ /**
101
+ * Extra time a restore lease counts as live beyond the transfer budget, covering
102
+ * the Machine boot and the API round trips that bracket the transfer itself.
103
+ * A lease older than `transferTimeoutMs + this` cannot belong to a live restore,
104
+ * because the restore's own exec is bounded by that budget.
105
+ */
106
+ export declare const LEASE_GRACE_MS = 120000;
107
+ /** Exit status the capture script uses when the archive is over the size ceiling. */
108
+ export declare const EXIT_ARCHIVE_TOO_LARGE = 90;
109
+ /** Exit status the restore script uses when a setuid/setgid file survived the sweep. */
110
+ export declare const EXIT_SETUID_SURVIVED = 91;
111
+ /** Exit status the capture script uses when `tar` failed outright (as opposed to warning). */
112
+ export declare const EXIT_TAR_FAILED = 92;
113
+ /** Marker the capture script prints on stderr when `tar` reported changed files. */
114
+ export declare const TAR_CHANGED_MARKER = "MOL_TAR_FILES_CHANGED";
115
+ /** The control-plane-written record of a template. Its existence defines the template's existence. */
116
+ export interface TemplateManifest {
117
+ /** Schema version of this record. */
118
+ schema: number;
119
+ /** The caller's template id. */
120
+ id: string;
121
+ /**
122
+ * Absolute paths captured, exactly as the caller named them.
123
+ *
124
+ * This is the security-relevant field: on restore these become `tar`'s member
125
+ * selectors, so the extraction surface is control-plane policy rather than
126
+ * anything the captured sandbox could influence.
127
+ */
128
+ capturePaths: string[];
129
+ /** When the capture completed, ISO 8601. */
130
+ createdAt: string;
131
+ /** Archive size in bytes as observed in the store at capture time. */
132
+ sizeBytes: number;
133
+ /** Free-form label recorded at capture time. */
134
+ label?: string;
135
+ }
136
+ /** What the template capability needs from the provider. */
137
+ export interface TemplateContext {
138
+ /** The configured object store, or `null` when the operator has not configured one. */
139
+ store: ObjectStore | null;
140
+ /** Runs a shell command inside a Machine and returns its result. */
141
+ exec(app: string, machineId: string, command: string, timeoutMs: number): Promise<ExecResult>;
142
+ /** Blocks until a Machine is running, so it can be exec'd into. */
143
+ ensureStarted(app: string, machineId: string): Promise<void>;
144
+ /** Splits a composite sandbox id into its app and Machine id. */
145
+ parseSandboxId(id: string): {
146
+ app: string;
147
+ machineId: string;
148
+ };
149
+ /** Lifetime of a presigned capture/restore URL, in seconds. */
150
+ presignExpirySeconds: number;
151
+ /** Wall-clock budget for a capture or restore transfer, in ms. */
152
+ transferTimeoutMs: number;
153
+ /** Largest archive this provider will store, in bytes. */
154
+ maxArchiveBytes: number;
155
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
156
+ debug?: (message: string, meta?: Record<string, unknown>) => void;
157
+ }
158
+ /**
159
+ * Validate a caller-supplied template id.
160
+ * @param templateId - The caller's identifier.
161
+ * @throws {Error} When the id cannot be used verbatim as an object-key segment.
162
+ */
163
+ export declare function assertTemplateId(templateId: string): void;
164
+ /**
165
+ * Validate a path that will be interpolated into a `sh -c` command AND used as a
166
+ * `tar` member selector.
167
+ * @param path - Candidate absolute path inside the sandbox.
168
+ * @throws {Error} When the path is not a plain, absolute, traversal-free path.
169
+ */
170
+ export declare function assertCapturePath(path: string): void;
171
+ /**
172
+ * Converts an absolute capture path into the relative member name used in the
173
+ * archive, so an archive of `/workspace` holds `workspace/…` and extracts with
174
+ * `tar -C /`.
175
+ * @param path - Absolute capture path.
176
+ * @returns The path with its leading and trailing slashes removed.
177
+ */
178
+ export declare function archiveMember(path: string): string;
179
+ /** Key of a template's manifest object. */
180
+ export declare function manifestKey(store: ObjectStore, templateId: string): string;
181
+ /** Key of a template's archive object. */
182
+ export declare function archiveKey(store: ObjectStore, templateId: string): string;
183
+ /** Key of one in-flight restore lease. */
184
+ export declare function leaseKey(store: ObjectStore, templateId: string, leaseId: string): string;
185
+ /**
186
+ * The provider-native reference for a template. OPAQUE to callers.
187
+ * @param store - The configured object store.
188
+ * @param templateId - The caller's identifier.
189
+ * @returns An `s3://bucket/key` reference to the archive.
190
+ */
191
+ export declare function templateRef(store: ObjectStore, templateId: string): string;
192
+ /**
193
+ * Require a configured object store, naming the settings that turn it on.
194
+ *
195
+ * Thrown rather than reported as "no such template": the capability is present —
196
+ * Fly plus any S3-compatible endpoint — and only the address is missing, so an
197
+ * operator needs to see the configuration error rather than watch every boot
198
+ * quietly rebuild from scratch.
199
+ * @param ctx - Template context.
200
+ * @returns The configured store.
201
+ * @throws {Error} When no store is configured.
202
+ */
203
+ export declare function requireStore(ctx: TemplateContext): ObjectStore;
204
+ /**
205
+ * Builds the in-sandbox capture command: archive the named paths, refuse an
206
+ * oversized result, and upload it to a presigned URL.
207
+ *
208
+ * The archive is written to a file BEFORE the upload rather than piped into
209
+ * `curl`, for two reasons that both bite: a pipeline reports only the last
210
+ * command's status, so a failed `tar` would upload a truncated archive and
211
+ * report success; and an upload from a pipe has no `Content-Length`, so `curl`
212
+ * sends `Transfer-Encoding: chunked`, which S3 rejects on a presigned `PUT`.
213
+ * @param paths - Absolute capture paths, already validated.
214
+ * @param url - Presigned `PUT` URL for the archive object.
215
+ * @param maxBytes - Largest archive that may be uploaded.
216
+ * @returns A `sh` script.
217
+ */
218
+ export declare function buildCaptureCommand(paths: string[], url: string, maxBytes: number): string;
219
+ /**
220
+ * Builds the in-sandbox restore command: download the archive, extract ONLY the
221
+ * manifest's paths, and prove no setuid/setgid file survived.
222
+ *
223
+ * See the module docs for why each flag is here. The download is to a file
224
+ * rather than piped into `tar` so a failed download cannot be reported as a
225
+ * successful extraction of a truncated stream.
226
+ * @param paths - Absolute capture paths from the CONTROL-PLANE manifest.
227
+ * @param url - Presigned `GET` URL for the archive object.
228
+ * @returns A `sh` script.
229
+ */
230
+ export declare function buildRestoreCommand(paths: string[], url: string): string;
231
+ /**
232
+ * Decides whether any lease means a restore is still in flight.
233
+ *
234
+ * A lease with no readable timestamp counts as LIVE. That is the whole rule the
235
+ * core states for this field: an unreadable answer resolves to in-use, never to
236
+ * free.
237
+ * @param leases - Lease objects for one template.
238
+ * @param ttlMs - How long a lease can possibly correspond to a live restore.
239
+ * @param now - Current time in ms since the epoch.
240
+ * @returns `true` when at least one lease is live.
241
+ */
242
+ export declare function hasLiveLease(leases: StoredObject[], ttlMs: number, now: number): boolean;
243
+ /**
244
+ * Read one template, and the manifest behind it, by the caller's identifier.
245
+ *
246
+ * Separate from {@link getTemplate} because the restore path needs the
247
+ * manifest's `capturePaths` — the extraction surface — and the public shape does
248
+ * not carry them.
249
+ * @param ctx - Template context.
250
+ * @param templateId - The caller's identifier.
251
+ * @returns The template and its manifest, or `null` when no usable template exists.
252
+ */
253
+ export declare function readTemplate(ctx: TemplateContext, templateId: string): Promise<{
254
+ template: SandboxTemplate;
255
+ manifest: TemplateManifest;
256
+ } | null>;
257
+ /**
258
+ * Read one template by the caller's identifier.
259
+ * @param ctx - Template context.
260
+ * @param templateId - The caller's identifier.
261
+ * @returns The template, or `null` when no template has that id.
262
+ */
263
+ export declare function getTemplate(ctx: TemplateContext, templateId: string): Promise<SandboxTemplate | null>;
264
+ /**
265
+ * Enumerate templates so the caller can apply its retention policy.
266
+ * @param ctx - Template context.
267
+ * @param options - Narrowing by id prefix.
268
+ * @returns Every matching template.
269
+ */
270
+ export declare function listTemplates(ctx: TemplateContext, options?: ListTemplatesOptions): Promise<SandboxTemplate[]>;
271
+ /**
272
+ * Capture a sandbox's filesystem into a reusable template.
273
+ * @param ctx - Template context.
274
+ * @param options - What to capture and what to call it.
275
+ * @returns The template as it now exists.
276
+ * @throws {Error} When no store is configured, no capture paths were given, the
277
+ * in-sandbox capture failed, or the archive cannot be read back.
278
+ */
279
+ export declare function commitTemplate(ctx: TemplateContext, options: CommitTemplateOptions): Promise<SandboxTemplate>;
280
+ /**
281
+ * Delete a template, refusing while a restore is still reading it.
282
+ * @param ctx - Template context.
283
+ * @param templateId - The caller's identifier.
284
+ * @throws {Error} When a restore is in flight, or the store cannot be read.
285
+ */
286
+ export declare function removeTemplate(ctx: TemplateContext, templateId: string): Promise<void>;
287
+ /**
288
+ * Records that a restore of this template is in flight.
289
+ *
290
+ * The lease lives in the store rather than in this process, so a second control
291
+ * plane enforcing a retention budget can see it. A control plane that dies
292
+ * mid-restore leaves the lease behind; it stops counting once it is older than
293
+ * the transfer budget, because no live restore can outlast that budget.
294
+ * @param ctx - Template context.
295
+ * @param templateId - The caller's identifier.
296
+ * @param leaseId - Unique id for this restore.
297
+ * @returns The lease's key, for release.
298
+ */
299
+ export declare function acquireRestoreLease(ctx: TemplateContext, templateId: string, leaseId: string): Promise<string>;
300
+ /**
301
+ * Releases a restore lease.
302
+ *
303
+ * Best-effort: the restore has already finished, and a stale lease only delays
304
+ * an eviction until it ages out. Failing the boot over it would trade a
305
+ * completed sandbox for a bookkeeping error.
306
+ * @param ctx - Template context.
307
+ * @param key - The lease key returned by {@link acquireRestoreLease}.
308
+ */
309
+ export declare function releaseRestoreLease(ctx: TemplateContext, key: string): Promise<void>;
310
+ //# sourceMappingURL=templates.d.ts.map