@alchemy.run/boat 0.0.0 → 2.0.0-beta.87
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/LICENSE +201 -0
- package/README.md +66 -4
- package/lib/AiProxy.d.ts +89 -0
- package/lib/AiProxy.d.ts.map +1 -0
- package/lib/AiProxy.js +125 -0
- package/lib/AiProxy.js.map +1 -0
- package/lib/ApiKey.d.ts +105 -0
- package/lib/ApiKey.d.ts.map +1 -0
- package/lib/ApiKey.js +134 -0
- package/lib/ApiKey.js.map +1 -0
- package/lib/AuthProvider.d.ts +71 -0
- package/lib/AuthProvider.d.ts.map +1 -0
- package/lib/AuthProvider.js +322 -0
- package/lib/AuthProvider.js.map +1 -0
- package/lib/BoatBindings.d.ts +52 -0
- package/lib/BoatBindings.d.ts.map +1 -0
- package/lib/BoatBindings.js +104 -0
- package/lib/BoatBindings.js.map +1 -0
- package/lib/Bootstrap.d.ts +8 -0
- package/lib/Bootstrap.d.ts.map +1 -0
- package/lib/Bootstrap.js +28 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/ConnectSandbox.d.ts +56 -0
- package/lib/ConnectSandbox.d.ts.map +1 -0
- package/lib/ConnectSandbox.js +3 -0
- package/lib/ConnectSandbox.js.map +1 -0
- package/lib/ConnectSandboxHttp.d.ts +21 -0
- package/lib/ConnectSandboxHttp.d.ts.map +1 -0
- package/lib/ConnectSandboxHttp.js +46 -0
- package/lib/ConnectSandboxHttp.js.map +1 -0
- package/lib/Contributions.d.ts +7 -0
- package/lib/Contributions.d.ts.map +1 -0
- package/lib/Contributions.js +6 -0
- package/lib/Contributions.js.map +1 -0
- package/lib/CreateSandbox.d.ts +134 -0
- package/lib/CreateSandbox.d.ts.map +1 -0
- package/lib/CreateSandbox.js +3 -0
- package/lib/CreateSandbox.js.map +1 -0
- package/lib/CreateSandboxHttp.d.ts +21 -0
- package/lib/CreateSandboxHttp.d.ts.map +1 -0
- package/lib/CreateSandboxHttp.js +93 -0
- package/lib/CreateSandboxHttp.js.map +1 -0
- package/lib/CredentialProfile.d.ts +157 -0
- package/lib/CredentialProfile.d.ts.map +1 -0
- package/lib/CredentialProfile.js +245 -0
- package/lib/CredentialProfile.js.map +1 -0
- package/lib/Credentials.d.ts +15 -0
- package/lib/Credentials.d.ts.map +1 -0
- package/lib/Credentials.js +23 -0
- package/lib/Credentials.js.map +1 -0
- package/lib/Environment.d.ts +219 -0
- package/lib/Environment.d.ts.map +1 -0
- package/lib/Environment.js +273 -0
- package/lib/Environment.js.map +1 -0
- package/lib/Errors.d.ts +50 -0
- package/lib/Errors.d.ts.map +1 -0
- package/lib/Errors.js +17 -0
- package/lib/Errors.js.map +1 -0
- package/lib/Providers.d.ts +33 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +47 -0
- package/lib/Providers.js.map +1 -0
- package/lib/Sandbox.d.ts +205 -0
- package/lib/Sandbox.d.ts.map +1 -0
- package/lib/Sandbox.js +368 -0
- package/lib/Sandbox.js.map +1 -0
- package/lib/SandboxClient.d.ts +159 -0
- package/lib/SandboxClient.d.ts.map +1 -0
- package/lib/SandboxClient.js +1 -0
- package/lib/SandboxClient.js.map +1 -0
- package/lib/SandboxEvents.d.ts +111 -0
- package/lib/SandboxEvents.d.ts.map +1 -0
- package/lib/SandboxEvents.js +60 -0
- package/lib/SandboxEvents.js.map +1 -0
- package/lib/SandboxEventsLive.d.ts +13 -0
- package/lib/SandboxEventsLive.d.ts.map +1 -0
- package/lib/SandboxEventsLive.js +63 -0
- package/lib/SandboxEventsLive.js.map +1 -0
- package/lib/Snapshot.d.ts +123 -0
- package/lib/Snapshot.d.ts.map +1 -0
- package/lib/Snapshot.js +173 -0
- package/lib/Snapshot.js.map +1 -0
- package/lib/Webhook.d.ts +56 -0
- package/lib/Webhook.d.ts.map +1 -0
- package/lib/Webhook.js +98 -0
- package/lib/Webhook.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +19 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/Declared.d.ts +34 -0
- package/lib/internal/Declared.d.ts.map +1 -0
- package/lib/internal/Declared.js +38 -0
- package/lib/internal/Declared.js.map +1 -0
- package/lib/internal/Org.d.ts +22 -0
- package/lib/internal/Org.d.ts.map +1 -0
- package/lib/internal/Org.js +23 -0
- package/lib/internal/Org.js.map +1 -0
- package/lib/internal/Ownership.d.ts +29 -0
- package/lib/internal/Ownership.d.ts.map +1 -0
- package/lib/internal/Ownership.js +50 -0
- package/lib/internal/Ownership.js.map +1 -0
- package/lib/internal/Program.d.ts +69 -0
- package/lib/internal/Program.d.ts.map +1 -0
- package/lib/internal/Program.js +178 -0
- package/lib/internal/Program.js.map +1 -0
- package/lib/internal/Ref.d.ts +14 -0
- package/lib/internal/Ref.d.ts.map +1 -0
- package/lib/internal/Ref.js +7 -0
- package/lib/internal/Ref.js.map +1 -0
- package/lib/internal/SandboxHandle.d.ts +17 -0
- package/lib/internal/SandboxHandle.d.ts.map +1 -0
- package/lib/internal/SandboxHandle.js +238 -0
- package/lib/internal/SandboxHandle.js.map +1 -0
- package/lib/internal/SavedSnapshot.d.ts +10 -0
- package/lib/internal/SavedSnapshot.d.ts.map +1 -0
- package/lib/internal/SavedSnapshot.js +22 -0
- package/lib/internal/SavedSnapshot.js.map +1 -0
- package/lib/internal/WebhookDelivery.d.ts +16 -0
- package/lib/internal/WebhookDelivery.d.ts.map +1 -0
- package/lib/internal/WebhookDelivery.js +109 -0
- package/lib/internal/WebhookDelivery.js.map +1 -0
- package/package.json +67 -3
- package/src/AiProxy.ts +171 -0
- package/src/ApiKey.ts +250 -0
- package/src/AuthProvider.ts +472 -0
- package/src/BoatBindings.ts +136 -0
- package/src/Bootstrap.ts +55 -0
- package/src/ConnectSandbox.ts +60 -0
- package/src/ConnectSandboxHttp.ts +59 -0
- package/src/Contributions.ts +9 -0
- package/src/CreateSandbox.ts +143 -0
- package/src/CreateSandboxHttp.ts +115 -0
- package/src/CredentialProfile.ts +350 -0
- package/src/Credentials.ts +33 -0
- package/src/Environment.ts +416 -0
- package/src/Errors.ts +34 -0
- package/src/Providers.ts +71 -0
- package/src/Sandbox.ts +647 -0
- package/src/SandboxClient.ts +200 -0
- package/src/SandboxEvents.ts +125 -0
- package/src/SandboxEventsLive.ts +88 -0
- package/src/Snapshot.ts +264 -0
- package/src/Webhook.ts +163 -0
- package/src/globals.d.ts +2 -0
- package/src/index.ts +18 -0
- package/src/internal/Declared.ts +87 -0
- package/src/internal/Org.ts +31 -0
- package/src/internal/Ownership.ts +75 -0
- package/src/internal/Program.ts +250 -0
- package/src/internal/Ref.ts +15 -0
- package/src/internal/SandboxHandle.ts +407 -0
- package/src/internal/SavedSnapshot.ts +34 -0
- package/src/internal/WebhookDelivery.ts +179 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type * as Boat from "@distilled.cloud/boat-dev";
|
|
2
|
+
import type * as boat from "@distilled.cloud/boat-dev/boat";
|
|
3
|
+
import type { RuntimeContext } from "alchemy/RuntimeContext";
|
|
4
|
+
import type * as Sandbox from "alchemy/Sandbox";
|
|
5
|
+
import type * as Duration from "effect/Duration";
|
|
6
|
+
import type * as Effect from "effect/Effect";
|
|
7
|
+
import type * as Redacted from "effect/Redacted";
|
|
8
|
+
import type * as Stream from "effect/Stream";
|
|
9
|
+
import type { BoatUnexpectedResponse, ExecTimedOut, SnapshotFailed } from "./Errors.ts";
|
|
10
|
+
|
|
11
|
+
/** Errors a Boat call can fail with. */
|
|
12
|
+
export type BoatError =
|
|
13
|
+
| Boat.BoatOpError
|
|
14
|
+
| boat.BadRequest
|
|
15
|
+
| boat.Forbidden
|
|
16
|
+
| boat.NotFound
|
|
17
|
+
| boat.Conflict
|
|
18
|
+
| boat.SandboxNotRunning
|
|
19
|
+
| boat.SandboxStartLimitExceeded
|
|
20
|
+
| boat.FileNotFound
|
|
21
|
+
| boat.ForkNeedsSnapshot
|
|
22
|
+
| boat.SaveInProgress
|
|
23
|
+
| ExecTimedOut
|
|
24
|
+
| SnapshotFailed
|
|
25
|
+
| BoatUnexpectedResponse;
|
|
26
|
+
|
|
27
|
+
/** A command: a shell line (run with `bash -lc`) or an argument vector. */
|
|
28
|
+
export type Command = Sandbox.Command;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `cwd` defaults to the home directory (`/home/user`). `timeout` (at most
|
|
32
|
+
* 10 minutes, Boat's default 30 seconds) stops the command and fails with
|
|
33
|
+
* `Boat.ExecTimedOut`.
|
|
34
|
+
*/
|
|
35
|
+
export type ExecOptions = Sandbox.ExecOptions;
|
|
36
|
+
|
|
37
|
+
/** What a finished command printed, and its exit code. A non-zero exit code is returned, not failed. */
|
|
38
|
+
export interface ExecResult extends Sandbox.ExecResult {
|
|
39
|
+
/** Boat's id for the command, for its history and logs. */
|
|
40
|
+
readonly commandId: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StartOptions {
|
|
44
|
+
/** Working directory. @default "/home/user" */
|
|
45
|
+
readonly cwd?: string;
|
|
46
|
+
/** Environment variables for the command. */
|
|
47
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A chunk of a process's output. */
|
|
51
|
+
export type ProcessOutput = Sandbox.ProcessOutput;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A command running in the background. It keeps running after the request
|
|
55
|
+
* that started it ends; `processes.attach(processId)` picks it up again.
|
|
56
|
+
* Boat has no stdin for background commands.
|
|
57
|
+
*/
|
|
58
|
+
export interface Process {
|
|
59
|
+
/** Boat's process id, as a string. */
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly processId: number;
|
|
62
|
+
/** stdout and stderr from where it was started or attached, until it exits. */
|
|
63
|
+
readonly output: Stream.Stream<ProcessOutput, BoatError, RuntimeContext>;
|
|
64
|
+
/** Stops the process. */
|
|
65
|
+
kill(): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
66
|
+
/** Waits for it to exit, with the tail of what it printed. */
|
|
67
|
+
readonly wait: Effect.Effect<ExecResult, BoatError, RuntimeContext>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Background commands in the sandbox. */
|
|
71
|
+
export interface Processes {
|
|
72
|
+
/** Picks up a background command by its process id. */
|
|
73
|
+
attach(processId: number): Effect.Effect<Process, BoatError, RuntimeContext>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A file or directory in the sandbox. */
|
|
77
|
+
export interface EntryInfo extends Sandbox.FileEntry {
|
|
78
|
+
/** Octal permission bits, e.g. `0644`. */
|
|
79
|
+
readonly mode: string;
|
|
80
|
+
readonly modifiedAt: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The sandbox's filesystem. Relative paths resolve in the home directory
|
|
85
|
+
* (`/home/user`); Boat reaches only paths under `/home/user` and `/tmp`.
|
|
86
|
+
* A missing path fails with `FileNotFound`.
|
|
87
|
+
*/
|
|
88
|
+
export interface Files extends Sandbox.Files<BoatError, EntryInfo> {
|
|
89
|
+
read(path: string): Effect.Effect<Uint8Array, BoatError, RuntimeContext>;
|
|
90
|
+
readText(path: string): Effect.Effect<string, BoatError, RuntimeContext>;
|
|
91
|
+
/** Writes a file, creating parent directories. Files up to 5 MiB. */
|
|
92
|
+
write(path: string, data: string | Uint8Array): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
93
|
+
list(path: string): Effect.Effect<ReadonlyArray<EntryInfo>, BoatError, RuntimeContext>;
|
|
94
|
+
stat(path: string): Effect.Effect<EntryInfo, BoatError, RuntimeContext>;
|
|
95
|
+
/** Removes a file, or a directory and everything in it. */
|
|
96
|
+
remove(path: string): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
97
|
+
/** Creates the directory and its parents. */
|
|
98
|
+
makeDir(path: string): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
99
|
+
exists(path: string): Effect.Effect<boolean, BoatError, RuntimeContext>;
|
|
100
|
+
/** Moves a file or directory, creating the destination's parents. */
|
|
101
|
+
move(
|
|
102
|
+
source: string,
|
|
103
|
+
destination: string,
|
|
104
|
+
options?: { readonly overwrite?: boolean },
|
|
105
|
+
): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A port inside a Boat sandbox, served at
|
|
110
|
+
* `https://<subdomain>-<port>.on.boat.dev`. A protected port needs its
|
|
111
|
+
* `token`, which `fetch` sends for you as Boat's `_port_auth` cookie.
|
|
112
|
+
*/
|
|
113
|
+
export interface Port extends Sandbox.SandboxPort<BoatError> {
|
|
114
|
+
/** The port's URL, without its token. */
|
|
115
|
+
readonly url: string;
|
|
116
|
+
/** The port's access token, or `undefined` for a public port. */
|
|
117
|
+
readonly token: Redacted.Redacted<string> | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface PortOptions {
|
|
121
|
+
/** Serve the port without a token, to anyone. @default false */
|
|
122
|
+
readonly public?: boolean;
|
|
123
|
+
/** Set when the program listens on `127.0.0.1` only; Boat relays to it. @default false */
|
|
124
|
+
readonly localhost?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What you can do with a sandbox, however you got it. Calls into a stopped
|
|
129
|
+
* sandbox (`exec`, `start`, `files`, `getTcpPort`) fail with
|
|
130
|
+
* `SandboxNotRunning`: `resume()` it first.
|
|
131
|
+
*/
|
|
132
|
+
export interface SandboxOperations
|
|
133
|
+
extends
|
|
134
|
+
Sandbox.SandboxExec<BoatError, ExecResult, ExecOptions>,
|
|
135
|
+
Sandbox.SandboxFiles<BoatError, EntryInfo>,
|
|
136
|
+
Sandbox.SandboxPorts<BoatError, Port> {
|
|
137
|
+
/** Runs a command to completion. */
|
|
138
|
+
exec(
|
|
139
|
+
command: Command,
|
|
140
|
+
options?: ExecOptions,
|
|
141
|
+
): Effect.Effect<ExecResult, BoatError, RuntimeContext>;
|
|
142
|
+
/** Starts a command in the background. */
|
|
143
|
+
start(
|
|
144
|
+
command: Command,
|
|
145
|
+
options?: StartOptions,
|
|
146
|
+
): Effect.Effect<Process, BoatError, RuntimeContext>;
|
|
147
|
+
readonly processes: Processes;
|
|
148
|
+
readonly files: Files;
|
|
149
|
+
/** A port inside the sandbox. Boat hosts it on first use. */
|
|
150
|
+
getTcpPort(port: number, options?: PortOptions): Effect.Effect<Port, BoatError, RuntimeContext>;
|
|
151
|
+
/** Live details: state, size, environment, labels. */
|
|
152
|
+
describe(): Effect.Effect<boat.Sandbox, BoatError, RuntimeContext>;
|
|
153
|
+
/**
|
|
154
|
+
* Stops the sandbox, saving its disk. Stopping a stopped sandbox
|
|
155
|
+
* succeeds.
|
|
156
|
+
*/
|
|
157
|
+
stop(): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
158
|
+
/**
|
|
159
|
+
* Resumes a stopped sandbox (a no-op on a running one). `autoStop` sets
|
|
160
|
+
* how long it runs before stopping again; `false` never stops it.
|
|
161
|
+
*/
|
|
162
|
+
resume(options?: {
|
|
163
|
+
readonly autoStop?: Duration.Input | false;
|
|
164
|
+
}): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
165
|
+
/** Sets how long the sandbox runs from now before it stops; `false` never stops it. */
|
|
166
|
+
setAutoStop(autoStop: Duration.Input | false): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
167
|
+
/** Saves the sandbox's disk as a named snapshot, and waits until it is ready. */
|
|
168
|
+
snapshot(name: string): Effect.Effect<boat.SavedSnapshot, BoatError, RuntimeContext>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A sandbox created or connected at runtime (`CreateSandbox`). Holds only
|
|
173
|
+
* its id, so it can be rebuilt with `connect(id)` in any request.
|
|
174
|
+
*/
|
|
175
|
+
export interface BoatSandbox extends SandboxOperations {
|
|
176
|
+
readonly sandboxId: string;
|
|
177
|
+
/** What Boat returned for the sandbox when it was created or connected. */
|
|
178
|
+
readonly info: boat.Sandbox;
|
|
179
|
+
/**
|
|
180
|
+
* A copy of the sandbox from its last completed snapshot. Fails with
|
|
181
|
+
* `ForkNeedsSnapshot` until it has one; stopping a sandbox snapshots it.
|
|
182
|
+
*/
|
|
183
|
+
fork(
|
|
184
|
+
options?: Omit<boat.ForkSandboxRequest, "sandboxId" | "ttlSeconds"> & {
|
|
185
|
+
/** How long the fork runs before Boat stops it; `false` never stops it. */
|
|
186
|
+
readonly autoStop?: Duration.Input | false;
|
|
187
|
+
},
|
|
188
|
+
): Effect.Effect<BoatSandbox, BoatError, RuntimeContext>;
|
|
189
|
+
/** Deletes the sandbox with its disk. Deleting one that is gone succeeds. */
|
|
190
|
+
delete(): Effect.Effect<void, BoatError, RuntimeContext>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A sandbox declared in the stack (`ConnectSandbox`). The same operations
|
|
195
|
+
* as {@link BoatSandbox} except `fork` and `delete`, which its key can't
|
|
196
|
+
* do; its id is read at runtime.
|
|
197
|
+
*/
|
|
198
|
+
export interface ConnectedSandbox extends SandboxOperations {
|
|
199
|
+
readonly sandboxId: Effect.Effect<string, never, RuntimeContext>;
|
|
200
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { sanitizeKey } from "alchemy/RuntimeContext";
|
|
2
|
+
import * as Context from "effect/Context";
|
|
3
|
+
import type * as Effect from "effect/Effect";
|
|
4
|
+
import type { WebhookEvent } from "./Webhook.ts";
|
|
5
|
+
|
|
6
|
+
/** A sandbox lifecycle event, as Boat delivers it to a webhook. */
|
|
7
|
+
export interface SandboxEvent {
|
|
8
|
+
/** The event's id. A redelivered event keeps it: deduplicate on it. */
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly type: WebhookEvent;
|
|
11
|
+
readonly createdAt: string;
|
|
12
|
+
readonly data: {
|
|
13
|
+
readonly sandbox: {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly name?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The sandbox's labels, read from Boat when the event is scoped to the
|
|
18
|
+
* stack. `undefined` with `scope: "account"`.
|
|
19
|
+
*/
|
|
20
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
21
|
+
};
|
|
22
|
+
/** On `sandbox.ready`, `sandbox.error` and `sandbox.archived`. */
|
|
23
|
+
readonly previousState?: string;
|
|
24
|
+
/** The state the sandbox is in now. */
|
|
25
|
+
readonly state?: string;
|
|
26
|
+
/** On `sandbox.degraded` and `sandbox.recovered`: why. */
|
|
27
|
+
readonly reason?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Which delivery attempt this is, from 1. */
|
|
30
|
+
readonly attempt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SandboxEventsProps {
|
|
34
|
+
/**
|
|
35
|
+
* The events to receive.
|
|
36
|
+
* @default every sandbox lifecycle event
|
|
37
|
+
*/
|
|
38
|
+
readonly events?: ReadonlyArray<WebhookEvent>;
|
|
39
|
+
/**
|
|
40
|
+
* Whose sandboxes. Boat sends a webhook every event in the account.
|
|
41
|
+
* `"stack"` hands the handler only the sandboxes this stack and stage own
|
|
42
|
+
* (declared, or created by its bindings), and adds each sandbox's labels
|
|
43
|
+
* to the event; `"account"` hands it everything.
|
|
44
|
+
* @default "stack"
|
|
45
|
+
*/
|
|
46
|
+
readonly scope?: "stack" | "account";
|
|
47
|
+
/**
|
|
48
|
+
* The path on the host Boat delivers to.
|
|
49
|
+
* @default "/__alchemy/boat/events"
|
|
50
|
+
*/
|
|
51
|
+
readonly path?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Receives sandbox lifecycle events on a host. */
|
|
55
|
+
export type SandboxEventsService = <Req = never>(
|
|
56
|
+
props: SandboxEventsProps,
|
|
57
|
+
process: (event: SandboxEvent) => Effect.Effect<void, never, Req>,
|
|
58
|
+
) => Effect.Effect<void, never, never>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Receive Boat sandbox lifecycle events (`sandbox.ready`,
|
|
62
|
+
* `sandbox.hydrated`, `sandbox.error`, `sandbox.archived`,
|
|
63
|
+
* `sandbox.degraded`, `sandbox.recovered`) in a Cloudflare Worker or any
|
|
64
|
+
* other webhook host.
|
|
65
|
+
*
|
|
66
|
+
* At deploy, a {@link Webhook} is created pointing at the host, and its
|
|
67
|
+
* signing secret is given to the host as a secret (a Cloudflare secret on
|
|
68
|
+
* a Worker). While the host runs,
|
|
69
|
+
* each delivery's `X-Ascii-Signature` (HMAC-SHA256 over the delivery id,
|
|
70
|
+
* timestamp and raw body) is checked before the handler runs; an unsigned,
|
|
71
|
+
* wrongly signed, altered or stale (over five minutes old) request is
|
|
72
|
+
* refused with 401, and bodies over 1 MB are refused unread. Boat retries a
|
|
73
|
+
* failed delivery, so handlers should be idempotent on `event.id`.
|
|
74
|
+
*
|
|
75
|
+
* Boat sends a webhook the events of every sandbox in the account, and an
|
|
76
|
+
* account has at most 10 webhooks. With the default `scope: "stack"`, the
|
|
77
|
+
* host looks each sandbox up with a key of its own, created at deploy and
|
|
78
|
+
* deleted with the host, that can only read sandboxes (`sandbox.read`), and drops
|
|
79
|
+
* events for sandboxes other stacks or stages own, and for sandboxes
|
|
80
|
+
* already deleted. The scope is a filter, not an access boundary: anyone
|
|
81
|
+
* in the account who can create sandboxes can label one with the stack's
|
|
82
|
+
* labels.
|
|
83
|
+
*
|
|
84
|
+
* ### Handling lifecycle events
|
|
85
|
+
* **Example:** Record when a sandbox stops
|
|
86
|
+
* ```typescript
|
|
87
|
+
* export default class Api extends Cloudflare.Worker<Api>()(
|
|
88
|
+
* "Api",
|
|
89
|
+
* { main: import.meta.url },
|
|
90
|
+
* Effect.gen(function* () {
|
|
91
|
+
* yield* Boat.consumeSandboxEvents({ events: ["sandbox.archived"] }, (event) =>
|
|
92
|
+
* Effect.log(`sandbox ${event.data.sandbox.id} stopped`),
|
|
93
|
+
* );
|
|
94
|
+
* return { fetch: Effect.succeed(HttpServerResponse.text("ok")) };
|
|
95
|
+
* }).pipe(Effect.provide(Boat.SandboxEventsLive)),
|
|
96
|
+
* ) {}
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* **Example:** Every sandbox in the account
|
|
100
|
+
* ```typescript
|
|
101
|
+
* yield* Boat.consumeSandboxEvents({ scope: "account", events: ["sandbox.error"] }, (event) =>
|
|
102
|
+
* Effect.logError(`sandbox ${event.data.sandbox.id} failed`),
|
|
103
|
+
* );
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* @binding
|
|
107
|
+
* @product Webhook
|
|
108
|
+
*/
|
|
109
|
+
export class SandboxEvents extends Context.Service<SandboxEvents, SandboxEventsService>()(
|
|
110
|
+
"Boat.SandboxEvents",
|
|
111
|
+
) {}
|
|
112
|
+
|
|
113
|
+
/** Subscribes the current host to sandbox lifecycle events. */
|
|
114
|
+
export const consumeSandboxEvents = <Req = never>(
|
|
115
|
+
props: SandboxEventsProps,
|
|
116
|
+
process: (event: SandboxEvent) => Effect.Effect<void, never, Req>,
|
|
117
|
+
) => SandboxEvents.use((source) => source(props, process));
|
|
118
|
+
|
|
119
|
+
/** Where Boat delivers on the host. */
|
|
120
|
+
export const sandboxEventsPath = (props: SandboxEventsProps): string =>
|
|
121
|
+
props.path ?? "/__alchemy/boat/events";
|
|
122
|
+
|
|
123
|
+
/** The env var the signing secret is given to the host under. */
|
|
124
|
+
export const sandboxEventsSecretEnvName = (path: string): string =>
|
|
125
|
+
`ALCHEMY_BOAT_WEBHOOK_SECRET_${sanitizeKey(path)}`;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { AlchemyContext } from "alchemy/AlchemyContext";
|
|
2
|
+
import * as Namespace from "alchemy/Namespace";
|
|
3
|
+
import * as Output from "alchemy/Output";
|
|
4
|
+
import { RuntimeContext } from "alchemy/RuntimeContext";
|
|
5
|
+
import { WebhookHost } from "alchemy/WebhookHost";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Layer from "effect/Layer";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
9
|
+
import { bindBoat, bindOwnerLabels } from "./BoatBindings.ts";
|
|
10
|
+
import { asSecret, handleDelivery } from "./internal/WebhookDelivery.ts";
|
|
11
|
+
import {
|
|
12
|
+
type SandboxEvent,
|
|
13
|
+
SandboxEvents,
|
|
14
|
+
type SandboxEventsProps,
|
|
15
|
+
type SandboxEventsService,
|
|
16
|
+
sandboxEventsPath,
|
|
17
|
+
sandboxEventsSecretEnvName,
|
|
18
|
+
} from "./SandboxEvents.ts";
|
|
19
|
+
import { ALL_SANDBOX_EVENTS, Webhook } from "./Webhook.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* {@link SandboxEvents} on any {@link WebhookHost}: every Cloudflare Worker
|
|
23
|
+
* is one.
|
|
24
|
+
*
|
|
25
|
+
* @layer
|
|
26
|
+
* @product Webhook
|
|
27
|
+
* @provides Boat.SandboxEvents
|
|
28
|
+
*/
|
|
29
|
+
export const SandboxEventsLive = Layer.effect(
|
|
30
|
+
SandboxEvents,
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const host = yield* WebhookHost;
|
|
33
|
+
// Yielding the resource class erases its `Providers` requirement; the
|
|
34
|
+
// stack satisfies it at plan time.
|
|
35
|
+
const createWebhook = yield* Webhook;
|
|
36
|
+
|
|
37
|
+
return Effect.fn(function* (
|
|
38
|
+
props: SandboxEventsProps,
|
|
39
|
+
process: (event: SandboxEvent) => Effect.Effect<void, never, any>,
|
|
40
|
+
) {
|
|
41
|
+
const path = sandboxEventsPath(props);
|
|
42
|
+
|
|
43
|
+
// Boat generates the signing secret. It goes to the host as a binding,
|
|
44
|
+
// not a prop: the webhook depends on the host's URL.
|
|
45
|
+
const runtime = yield* RuntimeContext;
|
|
46
|
+
const secretKey = sandboxEventsSecretEnvName(path);
|
|
47
|
+
if (!globalThis.__ALCHEMY_RUNTIME__) {
|
|
48
|
+
yield* Namespace.push(
|
|
49
|
+
host.LogicalId,
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
// Boat delivers only to public HTTPS URLs, and under `alchemy dev`
|
|
52
|
+
// the host is on localhost: skip the webhook rather than fail.
|
|
53
|
+
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
54
|
+
if (Option.isSome(context) && context.value.dev) {
|
|
55
|
+
yield* Effect.logWarning(
|
|
56
|
+
`Boat.consumeSandboxEvents: no webhook for ${path} under alchemy dev; Boat only delivers to public URLs, so deploy to receive events.`,
|
|
57
|
+
);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const hook = yield* createWebhook(`BoatEvents${path}`, {
|
|
61
|
+
url: Output.interpolate`${host.url}${path}`,
|
|
62
|
+
events: [...(props.events ?? ALL_SANDBOX_EVENTS)],
|
|
63
|
+
});
|
|
64
|
+
yield* host.bind(`boat-webhook:${path}`, { env: { [secretKey]: hook.secret } });
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const boundSecret = Effect.map(runtime.get<unknown>(secretKey), asSecret);
|
|
69
|
+
|
|
70
|
+
// Scoped to the stack: the host reads each sandbox's labels with a
|
|
71
|
+
// key that may only read sandboxes.
|
|
72
|
+
const scope =
|
|
73
|
+
(props.scope ?? "stack") === "stack"
|
|
74
|
+
? {
|
|
75
|
+
auth: yield* bindBoat("BoatEventsKey", "BOAT_EVENTS_KEY", {
|
|
76
|
+
actions: ["sandbox.read"],
|
|
77
|
+
}),
|
|
78
|
+
owner: yield* bindOwnerLabels,
|
|
79
|
+
}
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
const types = new Set<string>(props.events ?? ALL_SANDBOX_EVENTS);
|
|
83
|
+
yield* host.route(path, (request) =>
|
|
84
|
+
handleDelivery(request, boundSecret, types, scope, process),
|
|
85
|
+
);
|
|
86
|
+
}) as SandboxEventsService;
|
|
87
|
+
}),
|
|
88
|
+
);
|
package/src/Snapshot.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as boat from "@distilled.cloud/boat-dev/boat";
|
|
2
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
3
|
+
import { isResolved } from "alchemy/Diff";
|
|
4
|
+
import * as Provider from "alchemy/Provider";
|
|
5
|
+
import { Resource } from "alchemy/Resource";
|
|
6
|
+
import { sha256Object } from "alchemy/Util/sha256";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import { SnapshotSetupFailed } from "./Errors.ts";
|
|
9
|
+
import { declarable } from "./internal/Declared.ts";
|
|
10
|
+
import { boatName, isGeneratedName, ownershipLabels } from "./internal/Ownership.ts";
|
|
11
|
+
import { awaitSaved, whileSaving } from "./internal/SavedSnapshot.ts";
|
|
12
|
+
import type { Providers } from "./Providers.ts";
|
|
13
|
+
import type { SandboxType } from "./Sandbox.ts";
|
|
14
|
+
|
|
15
|
+
/** A snapshot to start from: a `Boat.Snapshot`, declared or yielded, or `{ name }`. */
|
|
16
|
+
export interface SnapshotBase {
|
|
17
|
+
name: string;
|
|
18
|
+
/** A `Boat.Snapshot`'s setup hash. A rebuild replaces what starts from it. */
|
|
19
|
+
setupHash?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SnapshotProps {
|
|
23
|
+
/**
|
|
24
|
+
* Shell commands run in order before the snapshot is saved: clone a
|
|
25
|
+
* repository, install dependencies, warm a cache. A non-zero exit fails
|
|
26
|
+
* the deploy with `SnapshotSetupFailed`.
|
|
27
|
+
*/
|
|
28
|
+
run: string[];
|
|
29
|
+
/**
|
|
30
|
+
* The snapshot to start from: another `Boat.Snapshot`, or `{ name }` of
|
|
31
|
+
* one saved elsewhere. Omit to start from a fresh sandbox. Rebuilding it
|
|
32
|
+
* takes this snapshot again.
|
|
33
|
+
*/
|
|
34
|
+
from?: SnapshotBase;
|
|
35
|
+
/** Environment variables for the setup commands. */
|
|
36
|
+
env?: Record<string, string>;
|
|
37
|
+
/** Working directory for the setup commands. @default "/home/user" */
|
|
38
|
+
cwd?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Machine size to build on. Sandboxes started from the snapshot default
|
|
41
|
+
* to it.
|
|
42
|
+
* @default "default"
|
|
43
|
+
*/
|
|
44
|
+
type?: SandboxType;
|
|
45
|
+
/**
|
|
46
|
+
* Snapshot name: lowercase letters, digits and dashes, up to 63. If
|
|
47
|
+
* omitted, a unique name is generated from the stack, stage and logical
|
|
48
|
+
* ID. Changing it replaces the snapshot.
|
|
49
|
+
*/
|
|
50
|
+
name?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SnapshotAttributes {
|
|
54
|
+
/** The name sandboxes start from. */
|
|
55
|
+
name: string;
|
|
56
|
+
/** Boat's id for the saved disk. */
|
|
57
|
+
snapshotId: string | undefined;
|
|
58
|
+
/** The machine size it was saved from. */
|
|
59
|
+
type: string | undefined;
|
|
60
|
+
/** Restored size, in bytes. */
|
|
61
|
+
sizeBytes: number | undefined;
|
|
62
|
+
/** Content hash of the base and setup. A deploy re-saves only when it changes. */
|
|
63
|
+
setupHash: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Snapshot extends Resource<
|
|
67
|
+
"Boat.Snapshot",
|
|
68
|
+
SnapshotProps,
|
|
69
|
+
SnapshotAttributes,
|
|
70
|
+
never,
|
|
71
|
+
Providers,
|
|
72
|
+
{ name: string }
|
|
73
|
+
> {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A warm base for sandboxes: a sandbox set up with your commands and its
|
|
77
|
+
* disk saved under a name. Sandboxes started from it begin exactly where
|
|
78
|
+
* the setup left off: packages installed, a repository cloned, a build
|
|
79
|
+
* cache warm.
|
|
80
|
+
*
|
|
81
|
+
* Use a snapshot for work that is too slow to repeat per sandbox. Alchemy
|
|
82
|
+
* runs the commands in a sandbox of its own, saves it, and deletes the
|
|
83
|
+
* sandbox. A deploy re-runs the setup only when the commands or the base
|
|
84
|
+
* change, and a rebuild replaces the declared sandboxes started from it.
|
|
85
|
+
*
|
|
86
|
+
* ### Creating a Snapshot
|
|
87
|
+
* **Example:** A repository with its dependencies installed
|
|
88
|
+
* ```typescript
|
|
89
|
+
* export const Golden = Boat.Snapshot("Golden", {
|
|
90
|
+
* run: [
|
|
91
|
+
* "git clone https://github.com/alchemy-run/alchemy ~/alchemy",
|
|
92
|
+
* "cd ~/alchemy && pnpm install",
|
|
93
|
+
* ],
|
|
94
|
+
* });
|
|
95
|
+
* ```
|
|
96
|
+
*
|
|
97
|
+
* **Example:** Layer one snapshot on another
|
|
98
|
+
* ```typescript
|
|
99
|
+
* export const Tools = Boat.Snapshot("Tools", { run: ["npm i -g pnpm"] });
|
|
100
|
+
* export const Golden = Boat.Snapshot("Golden", { from: Tools, run: ["pnpm dlx playwright install"] });
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* ### Starting sandboxes from a Snapshot
|
|
104
|
+
* **Example:** A declared sandbox
|
|
105
|
+
* ```typescript
|
|
106
|
+
* export const Box = Boat.Sandbox("Box", { snapshot: Golden });
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* **Example:** Sandboxes created at run time
|
|
110
|
+
* ```typescript
|
|
111
|
+
* const sandboxes = yield* Boat.CreateSandbox(Golden);
|
|
112
|
+
* const sandbox = yield* sandboxes.create({ autoStop: "10 minutes" });
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* ### Referencing a snapshot the stack doesn't manage
|
|
116
|
+
* **Example:** Look one up by name
|
|
117
|
+
* ```typescript
|
|
118
|
+
* const shared = Boat.Snapshot.lookup({ name: "team-base" });
|
|
119
|
+
* const sandboxes = yield* Boat.CreateSandbox(shared);
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* @resource
|
|
123
|
+
* @product Snapshot
|
|
124
|
+
*/
|
|
125
|
+
export const Snapshot = declarable<Snapshot>()(Resource<Snapshot>("Boat.Snapshot"));
|
|
126
|
+
|
|
127
|
+
/** Boat snapshot names: lowercase letters, digits and dashes. */
|
|
128
|
+
const snapshotName = (id: string, name: string | undefined) =>
|
|
129
|
+
Effect.map(boatName(id, name, 63), (value) =>
|
|
130
|
+
name !== undefined ? value : value.replace(/[^a-z0-9-]/g, "-"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const setupHashOf = (props: SnapshotProps) =>
|
|
134
|
+
Effect.map(
|
|
135
|
+
sha256Object({
|
|
136
|
+
from: props.from === undefined ? null : [props.from.name, props.from.setupHash ?? null],
|
|
137
|
+
run: props.run,
|
|
138
|
+
env: props.env ?? {},
|
|
139
|
+
cwd: props.cwd ?? null,
|
|
140
|
+
type: props.type ?? "default",
|
|
141
|
+
}),
|
|
142
|
+
(hash) => hash.slice(0, 16),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const observeSnapshot = (name: string) =>
|
|
146
|
+
boat.getSavedSnapshot({ name }).pipe(
|
|
147
|
+
Effect.map((got): boat.SavedSnapshot | undefined => got.snapshot),
|
|
148
|
+
Effect.catchTag("NotFound", () => Effect.undefined),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const toAttributes = (snapshot: boat.SavedSnapshot, setupHash: string): SnapshotAttributes => ({
|
|
152
|
+
name: snapshot.name,
|
|
153
|
+
snapshotId: snapshot.snapshotId,
|
|
154
|
+
type: snapshot.type,
|
|
155
|
+
sizeBytes: snapshot.sizeBytes,
|
|
156
|
+
setupHash,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** POSIX single quotes: the value verbatim. */
|
|
160
|
+
const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Starts a setup sandbox (from `from`, with none of the account's secrets),
|
|
164
|
+
* runs the commands, saves it as `name`, and always deletes the sandbox.
|
|
165
|
+
*/
|
|
166
|
+
const takeSnapshot = Effect.fn(function* (id: string, name: string, props: SnapshotProps) {
|
|
167
|
+
const labels = yield* ownershipLabels(id);
|
|
168
|
+
return yield* Effect.acquireUseRelease(
|
|
169
|
+
boat.createSandbox({
|
|
170
|
+
noEnv: true,
|
|
171
|
+
ttlSeconds: 1800,
|
|
172
|
+
...(props.type === undefined ? {} : { type: props.type }),
|
|
173
|
+
...(props.from === undefined ? {} : { from: props.from.name }),
|
|
174
|
+
labels,
|
|
175
|
+
}),
|
|
176
|
+
({ sandbox }) =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
const exports = Object.entries(props.env ?? {})
|
|
179
|
+
.map(([key, value]) => `export ${key}=${quote(value)}; `)
|
|
180
|
+
.join("");
|
|
181
|
+
for (const command of props.run) {
|
|
182
|
+
const result = yield* boat.executeSandboxCommand({
|
|
183
|
+
sandboxId: sandbox.id,
|
|
184
|
+
command: `${exports}${command}`,
|
|
185
|
+
timeoutSeconds: 600,
|
|
186
|
+
...(props.cwd === undefined ? {} : { cwd: props.cwd }),
|
|
187
|
+
});
|
|
188
|
+
const exitCode = "exitCode" in result ? (result.exitCode ?? -1) : -1;
|
|
189
|
+
const timedOut = "timedOut" in result && result.timedOut === true;
|
|
190
|
+
if (exitCode !== 0 || timedOut) {
|
|
191
|
+
return yield* new SnapshotSetupFailed({
|
|
192
|
+
message: timedOut
|
|
193
|
+
? `snapshot ${name}: \`${command}\` ran past 10 minutes`
|
|
194
|
+
: `snapshot ${name}: \`${command}\` exited with ${exitCode}`,
|
|
195
|
+
command,
|
|
196
|
+
exitCode,
|
|
197
|
+
stderr: "stderr" in result ? result.stderr.slice(-4000) : "",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// An earlier save under this name may still be running.
|
|
202
|
+
yield* whileSaving(boat.saveSavedSnapshot({ sandboxId: sandbox.id, name }));
|
|
203
|
+
return yield* awaitSaved(name);
|
|
204
|
+
}),
|
|
205
|
+
({ sandbox }) =>
|
|
206
|
+
Effect.ignore(boat.deleteSandbox({ sandboxId: sandbox.id, xAsciiConfirmDelete: sandbox.id })),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
export const SnapshotProvider = () =>
|
|
211
|
+
Provider.effect(
|
|
212
|
+
Snapshot,
|
|
213
|
+
Effect.gen(function* () {
|
|
214
|
+
return {
|
|
215
|
+
stables: ["name"],
|
|
216
|
+
diff: Effect.fn(function* ({ id, news, olds, output }) {
|
|
217
|
+
if (!isResolved(news) || olds === undefined) return undefined;
|
|
218
|
+
const name = yield* snapshotName(id, news.name ?? output?.name);
|
|
219
|
+
if (output !== undefined && name !== output.name) return { action: "replace" } as const;
|
|
220
|
+
if (output !== undefined && (yield* setupHashOf(news)) !== output.setupHash) {
|
|
221
|
+
return { action: "update" } as const;
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}),
|
|
225
|
+
read: Effect.fn(function* ({ id, olds, output }) {
|
|
226
|
+
const name = output?.name ?? (yield* snapshotName(id, olds?.name));
|
|
227
|
+
const snapshot = yield* observeSnapshot(name);
|
|
228
|
+
if (snapshot === undefined) return undefined;
|
|
229
|
+
const attrs = toAttributes(snapshot, output?.setupHash ?? "");
|
|
230
|
+
return output !== undefined || isGeneratedName(snapshot.name) ? attrs : Unowned(attrs);
|
|
231
|
+
}),
|
|
232
|
+
lookup: Effect.fn(function* ({ name }) {
|
|
233
|
+
const snapshot = yield* observeSnapshot(name);
|
|
234
|
+
return snapshot === undefined ? undefined : toAttributes(snapshot, "");
|
|
235
|
+
}),
|
|
236
|
+
reconcile: Effect.fn(function* ({ id, news, output, session }) {
|
|
237
|
+
const name = yield* snapshotName(id, news.name ?? output?.name);
|
|
238
|
+
const setupHash = yield* setupHashOf(news);
|
|
239
|
+
|
|
240
|
+
// Observe — a ready snapshot of this exact setup is kept.
|
|
241
|
+
const observed = yield* observeSnapshot(name);
|
|
242
|
+
if (observed?.status === "ready" && output?.setupHash === setupHash) {
|
|
243
|
+
return toAttributes(observed, setupHash);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Ensure — saving under an existing name replaces its disk.
|
|
247
|
+
yield* session.note(`Building snapshot ${name}...`);
|
|
248
|
+
return toAttributes(yield* takeSnapshot(id, name, news), setupHash);
|
|
249
|
+
}),
|
|
250
|
+
delete: Effect.fn(function* ({ output }) {
|
|
251
|
+
// A save under this name (an interrupted deploy's) must settle first.
|
|
252
|
+
yield* whileSaving(boat.deleteSavedSnapshot({ name: output.name })).pipe(
|
|
253
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
254
|
+
);
|
|
255
|
+
}),
|
|
256
|
+
list: Effect.fn(function* () {
|
|
257
|
+
const listed = yield* boat.listSavedSnapshots({});
|
|
258
|
+
return listed.snapshots
|
|
259
|
+
.filter((snapshot) => isGeneratedName(snapshot.name))
|
|
260
|
+
.map((snapshot) => toAttributes(snapshot, ""));
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
}),
|
|
264
|
+
);
|