@alchemy.run/docker 0.0.0 → 2.0.0-beta.85
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 +46 -4
- package/lib/Bootstrap.d.ts +7 -0
- package/lib/Bootstrap.d.ts.map +1 -0
- package/lib/Bootstrap.js +25 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/BuildHash.d.ts +56 -0
- package/lib/BuildHash.d.ts.map +1 -0
- package/lib/BuildHash.js +172 -0
- package/lib/BuildHash.js.map +1 -0
- package/lib/Container.d.ts +450 -0
- package/lib/Container.d.ts.map +1 -0
- package/lib/Container.js +745 -0
- package/lib/Container.js.map +1 -0
- package/lib/Context.d.ts +86 -0
- package/lib/Context.d.ts.map +1 -0
- package/lib/Context.js +146 -0
- package/lib/Context.js.map +1 -0
- package/lib/Docker.d.ts +527 -0
- package/lib/Docker.d.ts.map +1 -0
- package/lib/Docker.js +437 -0
- package/lib/Docker.js.map +1 -0
- package/lib/Dockerfile.d.ts +72 -0
- package/lib/Dockerfile.d.ts.map +1 -0
- package/lib/Dockerfile.js +12 -0
- package/lib/Dockerfile.js.map +1 -0
- package/lib/HealthcheckCommand.d.ts +17 -0
- package/lib/HealthcheckCommand.d.ts.map +1 -0
- package/lib/HealthcheckCommand.js +33 -0
- package/lib/HealthcheckCommand.js.map +1 -0
- package/lib/Image.d.ts +179 -0
- package/lib/Image.d.ts.map +1 -0
- package/lib/Image.js +210 -0
- package/lib/Image.js.map +1 -0
- package/lib/ImageSource.d.ts +288 -0
- package/lib/ImageSource.d.ts.map +1 -0
- package/lib/ImageSource.js +498 -0
- package/lib/ImageSource.js.map +1 -0
- package/lib/Network.d.ts +70 -0
- package/lib/Network.d.ts.map +1 -0
- package/lib/Network.js +111 -0
- package/lib/Network.js.map +1 -0
- package/lib/Providers.d.ts +21 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +26 -0
- package/lib/Providers.js.map +1 -0
- package/lib/Registry.d.ts +47 -0
- package/lib/Registry.d.ts.map +1 -0
- package/lib/Registry.js +67 -0
- package/lib/Registry.js.map +1 -0
- package/lib/RegistryError.d.ts +21 -0
- package/lib/RegistryError.d.ts.map +1 -0
- package/lib/RegistryError.js +28 -0
- package/lib/RegistryError.js.map +1 -0
- package/lib/RemoteImage.d.ts +118 -0
- package/lib/RemoteImage.d.ts.map +1 -0
- package/lib/RemoteImage.js +164 -0
- package/lib/RemoteImage.js.map +1 -0
- package/lib/Service.d.ts +499 -0
- package/lib/Service.d.ts.map +1 -0
- package/lib/Service.js +730 -0
- package/lib/Service.js.map +1 -0
- package/lib/ServiceImage.d.ts +78 -0
- package/lib/ServiceImage.d.ts.map +1 -0
- package/lib/ServiceImage.js +162 -0
- package/lib/ServiceImage.js.map +1 -0
- package/lib/Swarm.d.ts +119 -0
- package/lib/Swarm.d.ts.map +1 -0
- package/lib/Swarm.js +148 -0
- package/lib/Swarm.js.map +1 -0
- package/lib/Volume.d.ts +94 -0
- package/lib/Volume.d.ts.map +1 -0
- package/lib/Volume.js +133 -0
- package/lib/Volume.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/package.json +58 -3
- package/src/Bootstrap.ts +51 -0
- package/src/BuildHash.ts +265 -0
- package/src/Container.ts +1118 -0
- package/src/Context.ts +215 -0
- package/src/Docker.ts +1173 -0
- package/src/Dockerfile.ts +81 -0
- package/src/HealthcheckCommand.ts +35 -0
- package/src/Image.ts +322 -0
- package/src/ImageSource.ts +842 -0
- package/src/Network.ts +169 -0
- package/src/Providers.ts +46 -0
- package/src/Registry.ts +91 -0
- package/src/RegistryError.ts +48 -0
- package/src/RemoteImage.ts +255 -0
- package/src/Service.ts +1264 -0
- package/src/ServiceImage.ts +252 -0
- package/src/Swarm.ts +221 -0
- package/src/Volume.ts +193 -0
- package/src/index.ts +17 -0
package/src/Docker.ts
ADDED
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
2
|
+
import type { ScopedPlanStatusSession } from "alchemy/Report";
|
|
3
|
+
import * as Config from "effect/Config";
|
|
4
|
+
import * as Context from "effect/Context";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import { Base64 } from "effect/encoding";
|
|
7
|
+
import * as FileSystem from "effect/FileSystem";
|
|
8
|
+
import { flow } from "effect/Function";
|
|
9
|
+
import * as Layer from "effect/Layer";
|
|
10
|
+
import * as Option from "effect/Option";
|
|
11
|
+
import * as Path from "effect/Path";
|
|
12
|
+
import { PlatformError, SystemError, type SystemErrorTag } from "effect/PlatformError";
|
|
13
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
14
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
15
|
+
import * as Redacted from "effect/Redacted";
|
|
16
|
+
import * as Result from "effect/Result";
|
|
17
|
+
import * as Schema from "effect/Schema";
|
|
18
|
+
import * as Sink from "effect/Sink";
|
|
19
|
+
import * as Stream from "effect/Stream";
|
|
20
|
+
import { classifyDockerRegistryError, type DockerImagePublicationError } from "./RegistryError.ts";
|
|
21
|
+
|
|
22
|
+
/** Credentials for a single image registry, scoped to a Docker command. */
|
|
23
|
+
export interface RegistryCredentials {
|
|
24
|
+
/** Registry hostname, without a URL scheme. */
|
|
25
|
+
server: string;
|
|
26
|
+
/** Registry authentication username. */
|
|
27
|
+
username: string;
|
|
28
|
+
/** Registry password or access token. */
|
|
29
|
+
password: string | Redacted.Redacted<string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RegistryAuth = Schema.String.pipe(
|
|
33
|
+
Schema.check(
|
|
34
|
+
Schema.makeFilter((value) => {
|
|
35
|
+
const decoded = Base64.decode(value);
|
|
36
|
+
return Result.isSuccess(decoded) && decoded.success.indexOf(58) > 0;
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const decodeRegistryAuthConfig = Schema.Struct({
|
|
42
|
+
auths: Schema.Record(Schema.String, Schema.Struct({ auth: RegistryAuth })).pipe(
|
|
43
|
+
Schema.NullOr,
|
|
44
|
+
Schema.optional,
|
|
45
|
+
),
|
|
46
|
+
}).pipe(Schema.fromJsonString, (schema) =>
|
|
47
|
+
Schema.decodeEffect(schema, { onExcessProperty: "error" }),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export class Docker extends Context.Service<
|
|
51
|
+
Docker,
|
|
52
|
+
{
|
|
53
|
+
/** Runs a Docker command and returns the output. Use this to run a command that doesn't have a dedicated method. */
|
|
54
|
+
readonly run: (args: Array<string>) => Effect.Effect<CommandOutput, PlatformError>;
|
|
55
|
+
/** Writes build files and an inline Dockerfile to the given context directory. */
|
|
56
|
+
readonly materialize: (options: {
|
|
57
|
+
context: string;
|
|
58
|
+
dockerfile: string;
|
|
59
|
+
files: ReadonlyArray<{ path: string; content: string | Uint8Array }>;
|
|
60
|
+
}) => Effect.Effect<void, PlatformError>;
|
|
61
|
+
readonly container: {
|
|
62
|
+
/** Creates a new container. */
|
|
63
|
+
readonly create: (options: {
|
|
64
|
+
name: string;
|
|
65
|
+
image: string;
|
|
66
|
+
volume: Array<string> | undefined;
|
|
67
|
+
env: Record<string, string> | undefined;
|
|
68
|
+
/** Paths to Docker env files, forwarded as repeated `--env-file`. */
|
|
69
|
+
"env-file"?: Array<string> | undefined;
|
|
70
|
+
restart: "no" | "always" | "on-failure" | "unless-stopped";
|
|
71
|
+
rm: boolean;
|
|
72
|
+
"health-cmd": string | undefined;
|
|
73
|
+
/** `--no-healthcheck`: disable any healthcheck defined by the image. */
|
|
74
|
+
"no-healthcheck"?: boolean;
|
|
75
|
+
"health-interval": string | undefined;
|
|
76
|
+
"health-timeout": string | undefined;
|
|
77
|
+
"health-retries": number | undefined;
|
|
78
|
+
"health-start-period": string | undefined;
|
|
79
|
+
"health-start-interval": string | undefined;
|
|
80
|
+
"stop-timeout": string | undefined;
|
|
81
|
+
p: Array<string> | undefined;
|
|
82
|
+
/** `--add-host` entries, each `hostname:address`. */
|
|
83
|
+
"add-host"?: Array<string> | undefined;
|
|
84
|
+
/** Docker network namespace, including `container:<id>`. */
|
|
85
|
+
network?: string | undefined;
|
|
86
|
+
/** Linux capabilities to add. */
|
|
87
|
+
"cap-add"?: Array<string> | undefined;
|
|
88
|
+
/** Linux capabilities to drop. */
|
|
89
|
+
"cap-drop"?: Array<string> | undefined;
|
|
90
|
+
/** Hard memory limit in Docker's byte-suffix format. */
|
|
91
|
+
memory?: string | undefined;
|
|
92
|
+
/** Combined memory-and-swap limit in Docker's byte-suffix format. */
|
|
93
|
+
"memory-swap"?: string | undefined;
|
|
94
|
+
/** Soft memory reservation for memory contention. */
|
|
95
|
+
"memory-reservation"?: string | undefined;
|
|
96
|
+
/** CPU quota, in cores. */
|
|
97
|
+
cpus?: number | undefined;
|
|
98
|
+
/** Per-container process and thread limit. */
|
|
99
|
+
"pids-limit"?: number | undefined;
|
|
100
|
+
/** Mount the root filesystem read-only. */
|
|
101
|
+
"read-only"?: boolean | undefined;
|
|
102
|
+
/** Security options such as `no-new-privileges` and seccomp/AppArmor profiles. */
|
|
103
|
+
"security-opt"?: Array<string> | undefined;
|
|
104
|
+
/** Per-container user namespace; "host" disables daemon user remapping. */
|
|
105
|
+
userns?: string | undefined;
|
|
106
|
+
/** Supplemental Linux groups. */
|
|
107
|
+
"group-add"?: Array<string> | undefined;
|
|
108
|
+
/** Per-process soft/hard resource limits. */
|
|
109
|
+
ulimit?: Array<string> | undefined;
|
|
110
|
+
/** Docker log driver. */
|
|
111
|
+
"log-driver"?: string | undefined;
|
|
112
|
+
/** Driver-specific Docker logging options. */
|
|
113
|
+
"log-opt"?: Array<string> | undefined;
|
|
114
|
+
/** Kernel OOM score adjustment. */
|
|
115
|
+
"oom-score-adj"?: number | undefined;
|
|
116
|
+
/** Signal used on container stop. */
|
|
117
|
+
"stop-signal"?: string | undefined;
|
|
118
|
+
/** Run Docker's tiny init process. */
|
|
119
|
+
init?: boolean | undefined;
|
|
120
|
+
/** Runtime UID/GID or named user. */
|
|
121
|
+
user?: string | undefined;
|
|
122
|
+
/** Docker tmpfs mount expressions (target[:options]). */
|
|
123
|
+
tmpfs?: Array<string> | undefined;
|
|
124
|
+
/** Host devices in Docker's `host:container[:permissions]` form. */
|
|
125
|
+
device?: Array<string> | undefined;
|
|
126
|
+
command: Array<string> | undefined;
|
|
127
|
+
label?: Record<string, string>;
|
|
128
|
+
context?: string;
|
|
129
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
130
|
+
/** Inspects a container. */
|
|
131
|
+
readonly inspect: (
|
|
132
|
+
name: string,
|
|
133
|
+
context?: string,
|
|
134
|
+
) => Effect.Effect<Docker.Container, PlatformError>;
|
|
135
|
+
/** Removes a container. */
|
|
136
|
+
readonly remove: (
|
|
137
|
+
name: string,
|
|
138
|
+
force?: boolean,
|
|
139
|
+
context?: string,
|
|
140
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
141
|
+
/** Starts a container. */
|
|
142
|
+
readonly start: (
|
|
143
|
+
name: string,
|
|
144
|
+
context?: string,
|
|
145
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
146
|
+
/** Stops a container. */
|
|
147
|
+
readonly stop: (
|
|
148
|
+
name: string,
|
|
149
|
+
context?: string,
|
|
150
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
151
|
+
};
|
|
152
|
+
readonly image: {
|
|
153
|
+
/**
|
|
154
|
+
* Builds locally, or publishes to a registry when credentials are
|
|
155
|
+
* supplied. With Buildx 0.26.0 or newer the image is exported straight
|
|
156
|
+
* from BuildKit (`buildx build --push`); older plugins build into the
|
|
157
|
+
* local image store and then `push`. If a session is provided, build
|
|
158
|
+
* logs will be emitted as session notes.
|
|
159
|
+
*/
|
|
160
|
+
readonly build: (
|
|
161
|
+
options: {
|
|
162
|
+
context: string;
|
|
163
|
+
/** Image tags to build and publish together. */
|
|
164
|
+
tag: string | [string, ...string[]];
|
|
165
|
+
file?: string;
|
|
166
|
+
platform?: string;
|
|
167
|
+
target?: string;
|
|
168
|
+
"build-arg"?: Record<string, string>;
|
|
169
|
+
/**
|
|
170
|
+
* BuildKit secrets, keyed by id (`RUN --mount=type=secret,id=<id>`).
|
|
171
|
+
* Values reach `docker` through its environment, never its argv,
|
|
172
|
+
* and are not written to the image.
|
|
173
|
+
*/
|
|
174
|
+
secrets?: Record<string, string | Redacted.Redacted<string>>;
|
|
175
|
+
"cache-from"?: Array<string>;
|
|
176
|
+
"cache-to"?: Array<string>;
|
|
177
|
+
args?: Array<string>;
|
|
178
|
+
engineContext?: string;
|
|
179
|
+
/** Registry auth for the build itself (base images, caches); publishes nothing. */
|
|
180
|
+
credentials?: RegistryCredentials;
|
|
181
|
+
},
|
|
182
|
+
session?: ScopedPlanStatusSession,
|
|
183
|
+
registry?: RegistryCredentials,
|
|
184
|
+
) => Effect.Effect<CommandOutput, DockerImagePublicationError>;
|
|
185
|
+
/** Pulls an image. */
|
|
186
|
+
readonly pull: (
|
|
187
|
+
ref: string,
|
|
188
|
+
platform?: string,
|
|
189
|
+
context?: string,
|
|
190
|
+
session?: Pick<ScopedPlanStatusSession, "note">,
|
|
191
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
192
|
+
/**
|
|
193
|
+
* Pushes an image to a registry. When `platform` is given, only that
|
|
194
|
+
* platform's manifest is pushed (`docker push --platform`) — with the
|
|
195
|
+
* containerd image store, a bare push sends every locally-present
|
|
196
|
+
* variant of a multi-arch tag, so a stale other-arch variant in the
|
|
197
|
+
* local cache would otherwise reach the registry. Engines whose store
|
|
198
|
+
* doesn't support the flag fall back to a plain push (their local tag
|
|
199
|
+
* is already narrowed by `pull --platform`).
|
|
200
|
+
*/
|
|
201
|
+
readonly push: (
|
|
202
|
+
ref: string,
|
|
203
|
+
credentials: RegistryCredentials,
|
|
204
|
+
platform?: string,
|
|
205
|
+
context?: string,
|
|
206
|
+
session?: Pick<ScopedPlanStatusSession, "note">,
|
|
207
|
+
) => Effect.Effect<CommandOutput, DockerImagePublicationError>;
|
|
208
|
+
/** Tags an image. */
|
|
209
|
+
readonly tag: (
|
|
210
|
+
source: string,
|
|
211
|
+
target: string,
|
|
212
|
+
context?: string,
|
|
213
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
214
|
+
/** Inspects an image. */
|
|
215
|
+
readonly inspect: (
|
|
216
|
+
ref: string,
|
|
217
|
+
context?: string,
|
|
218
|
+
) => Effect.Effect<Docker.Image, PlatformError>;
|
|
219
|
+
/** Removes an image. */
|
|
220
|
+
readonly remove: (
|
|
221
|
+
ref: string | Array<string>,
|
|
222
|
+
force?: boolean,
|
|
223
|
+
context?: string,
|
|
224
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
225
|
+
};
|
|
226
|
+
readonly volume: {
|
|
227
|
+
/** Creates a new volume. */
|
|
228
|
+
readonly create: (options: {
|
|
229
|
+
name: string;
|
|
230
|
+
driver?: string;
|
|
231
|
+
opt?: Record<string, string>;
|
|
232
|
+
label?: Record<string, string>;
|
|
233
|
+
context?: string;
|
|
234
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
235
|
+
/** Removes a volume. */
|
|
236
|
+
readonly remove: (
|
|
237
|
+
name: string,
|
|
238
|
+
context?: string,
|
|
239
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
240
|
+
/** Inspects a volume. */
|
|
241
|
+
readonly inspect: (
|
|
242
|
+
name: string,
|
|
243
|
+
context?: string,
|
|
244
|
+
) => Effect.Effect<Docker.Volume, PlatformError>;
|
|
245
|
+
};
|
|
246
|
+
readonly context: {
|
|
247
|
+
/** Creates a new Docker context. */
|
|
248
|
+
readonly create: (options: {
|
|
249
|
+
name: string;
|
|
250
|
+
description?: string;
|
|
251
|
+
/** Raw value for `--docker`, for example `host=ssh://user@host`. */
|
|
252
|
+
docker?: string;
|
|
253
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
254
|
+
/** Updates an existing Docker context. */
|
|
255
|
+
readonly update: (options: {
|
|
256
|
+
name: string;
|
|
257
|
+
description?: string;
|
|
258
|
+
/** Raw value for `--docker`, for example `host=ssh://user@host`. */
|
|
259
|
+
docker?: string;
|
|
260
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
261
|
+
/** Inspects a Docker context. */
|
|
262
|
+
readonly inspect: (name: string) => Effect.Effect<Docker.Context, PlatformError>;
|
|
263
|
+
/** Removes a Docker context. */
|
|
264
|
+
readonly remove: (
|
|
265
|
+
name: string,
|
|
266
|
+
force?: boolean,
|
|
267
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
268
|
+
};
|
|
269
|
+
readonly network: {
|
|
270
|
+
/** Creates a new network. */
|
|
271
|
+
readonly create: (options: {
|
|
272
|
+
name: string;
|
|
273
|
+
driver: string;
|
|
274
|
+
ipv6?: boolean;
|
|
275
|
+
label?: Record<string, string>;
|
|
276
|
+
context?: string;
|
|
277
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
278
|
+
/** Connects a container to a network. */
|
|
279
|
+
readonly connect: (options: {
|
|
280
|
+
network: string;
|
|
281
|
+
container: string;
|
|
282
|
+
alias?: string[];
|
|
283
|
+
context?: string;
|
|
284
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
285
|
+
/** Disconnects a container from a network. */
|
|
286
|
+
readonly disconnect: (options: {
|
|
287
|
+
network: string;
|
|
288
|
+
container: string;
|
|
289
|
+
context?: string;
|
|
290
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
291
|
+
/** Inspects a network. */
|
|
292
|
+
readonly inspect: (
|
|
293
|
+
name: string,
|
|
294
|
+
context?: string,
|
|
295
|
+
) => Effect.Effect<Docker.Network, PlatformError>;
|
|
296
|
+
/** Removes a network. */
|
|
297
|
+
readonly remove: (
|
|
298
|
+
id: string,
|
|
299
|
+
context?: string,
|
|
300
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
301
|
+
};
|
|
302
|
+
readonly swarm: {
|
|
303
|
+
/** Initializes swarm mode on the engine (`docker swarm init`). */
|
|
304
|
+
readonly init: (options: {
|
|
305
|
+
context?: string;
|
|
306
|
+
"advertise-addr"?: string;
|
|
307
|
+
"listen-addr"?: string;
|
|
308
|
+
"default-addr-pool"?: string[];
|
|
309
|
+
"default-addr-pool-mask-length"?: number;
|
|
310
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
311
|
+
/** Reads the engine's swarm state (`docker info --format '{{json .Swarm}}'`). */
|
|
312
|
+
readonly info: (context?: string) => Effect.Effect<Docker.SwarmInfo, PlatformError>;
|
|
313
|
+
/** Leaves the swarm (`docker swarm leave`). */
|
|
314
|
+
readonly leave: (
|
|
315
|
+
force?: boolean,
|
|
316
|
+
context?: string,
|
|
317
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
318
|
+
};
|
|
319
|
+
readonly service: {
|
|
320
|
+
/** Creates a new service. */
|
|
321
|
+
readonly create: (options: {
|
|
322
|
+
name: string;
|
|
323
|
+
image: string;
|
|
324
|
+
context?: string;
|
|
325
|
+
replicas?: number;
|
|
326
|
+
"endpoint-mode"?: "vip" | "dnsrr";
|
|
327
|
+
network?: string[];
|
|
328
|
+
constraint?: string[];
|
|
329
|
+
"replicas-max-per-node"?: number;
|
|
330
|
+
"placement-pref"?: string[];
|
|
331
|
+
"update-parallelism"?: number;
|
|
332
|
+
"update-delay"?: string;
|
|
333
|
+
"update-monitor"?: string;
|
|
334
|
+
"update-failure-action"?: "pause" | "continue" | "rollback";
|
|
335
|
+
"update-max-failure-ratio"?: number;
|
|
336
|
+
"update-order"?: "stop-first" | "start-first";
|
|
337
|
+
"rollback-parallelism"?: number;
|
|
338
|
+
"rollback-delay"?: string;
|
|
339
|
+
"rollback-monitor"?: string;
|
|
340
|
+
"rollback-failure-action"?: "pause" | "continue" | "rollback";
|
|
341
|
+
"rollback-max-failure-ratio"?: number;
|
|
342
|
+
"rollback-order"?: "stop-first" | "start-first";
|
|
343
|
+
"restart-condition"?: "none" | "on-failure" | "any";
|
|
344
|
+
"restart-delay"?: string;
|
|
345
|
+
"restart-max-attempts"?: number;
|
|
346
|
+
"restart-window"?: string;
|
|
347
|
+
"health-cmd"?: string;
|
|
348
|
+
/** `--no-healthcheck`: disable any healthcheck defined by the image. */
|
|
349
|
+
"no-healthcheck"?: boolean;
|
|
350
|
+
"health-interval"?: string;
|
|
351
|
+
"health-timeout"?: string;
|
|
352
|
+
"health-retries"?: number;
|
|
353
|
+
"health-start-period"?: string;
|
|
354
|
+
"stop-grace-period"?: string;
|
|
355
|
+
mount?: string[];
|
|
356
|
+
secret?: string[];
|
|
357
|
+
config?: string[];
|
|
358
|
+
"read-only"?: boolean;
|
|
359
|
+
publish?: string[];
|
|
360
|
+
label?: Record<string, string>;
|
|
361
|
+
env?: Record<string, string>;
|
|
362
|
+
command?: string[];
|
|
363
|
+
args?: string[];
|
|
364
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
365
|
+
/** Updates an existing service. */
|
|
366
|
+
readonly update: (options: {
|
|
367
|
+
id: string;
|
|
368
|
+
image: string;
|
|
369
|
+
context?: string;
|
|
370
|
+
replicas?: number;
|
|
371
|
+
"endpoint-mode"?: "vip" | "dnsrr";
|
|
372
|
+
/** Networks to detach, by name or id; applied before `network-add`. */
|
|
373
|
+
"network-rm"?: string[];
|
|
374
|
+
"network-add"?: string[];
|
|
375
|
+
"constraint-add"?: string[];
|
|
376
|
+
"constraint-rm"?: string[];
|
|
377
|
+
"replicas-max-per-node"?: number;
|
|
378
|
+
"update-parallelism"?: number;
|
|
379
|
+
"update-delay"?: string;
|
|
380
|
+
"update-monitor"?: string;
|
|
381
|
+
"update-failure-action"?: "pause" | "continue" | "rollback";
|
|
382
|
+
"update-max-failure-ratio"?: number;
|
|
383
|
+
"update-order"?: "stop-first" | "start-first";
|
|
384
|
+
"rollback-parallelism"?: number;
|
|
385
|
+
"rollback-delay"?: string;
|
|
386
|
+
"rollback-monitor"?: string;
|
|
387
|
+
"rollback-failure-action"?: "pause" | "continue" | "rollback";
|
|
388
|
+
"rollback-max-failure-ratio"?: number;
|
|
389
|
+
"rollback-order"?: "stop-first" | "start-first";
|
|
390
|
+
"restart-condition"?: "none" | "on-failure" | "any";
|
|
391
|
+
"restart-delay"?: string;
|
|
392
|
+
"restart-max-attempts"?: number;
|
|
393
|
+
"restart-window"?: string;
|
|
394
|
+
"health-cmd"?: string;
|
|
395
|
+
/** `--no-healthcheck`: disable any healthcheck defined by the image. */
|
|
396
|
+
"no-healthcheck"?: boolean;
|
|
397
|
+
"health-interval"?: string;
|
|
398
|
+
"health-timeout"?: string;
|
|
399
|
+
"health-retries"?: number;
|
|
400
|
+
"health-start-period"?: string;
|
|
401
|
+
"stop-grace-period"?: string;
|
|
402
|
+
"mount-add"?: string[];
|
|
403
|
+
/** Mount targets to remove. */
|
|
404
|
+
"mount-rm"?: string[];
|
|
405
|
+
"secret-add"?: string[];
|
|
406
|
+
/** Secret names to remove; applied before `secret-add`. */
|
|
407
|
+
"secret-rm"?: string[];
|
|
408
|
+
"config-add"?: string[];
|
|
409
|
+
/** Config names to remove; applied before `config-add`. */
|
|
410
|
+
"config-rm"?: string[];
|
|
411
|
+
"read-only"?: boolean;
|
|
412
|
+
"publish-add"?: string[];
|
|
413
|
+
/** Published ports to remove, as `target=<port>,protocol=<p>,mode=<m>`. */
|
|
414
|
+
"publish-rm"?: string[];
|
|
415
|
+
"label-add"?: Record<string, string>;
|
|
416
|
+
"label-rm"?: string[];
|
|
417
|
+
"env-add"?: Record<string, string>;
|
|
418
|
+
/** Environment variable names to remove. */
|
|
419
|
+
"env-rm"?: string[];
|
|
420
|
+
args?: string;
|
|
421
|
+
}) => Effect.Effect<CommandOutput, PlatformError>;
|
|
422
|
+
/** Inspects a service. */
|
|
423
|
+
readonly inspect: (
|
|
424
|
+
id: string,
|
|
425
|
+
context?: string,
|
|
426
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
427
|
+
/** Removes a service. */
|
|
428
|
+
readonly remove: (
|
|
429
|
+
id: string,
|
|
430
|
+
context?: string,
|
|
431
|
+
) => Effect.Effect<CommandOutput, PlatformError>;
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
>()("@alchemy/Docker") {}
|
|
435
|
+
|
|
436
|
+
export declare namespace Docker {
|
|
437
|
+
export type ContextRef = string | { name: string };
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A reference to the Docker engine an operation runs against: a context
|
|
441
|
+
* name, a `Docker.Context` resource, or a `Docker.Swarm` resource
|
|
442
|
+
* (narrowed structurally by its `nodeId` attribute) — passing the swarm
|
|
443
|
+
* also orders the operation after the swarm is initialized.
|
|
444
|
+
*/
|
|
445
|
+
export type EngineRef = ContextRef | { nodeId: string; context?: string };
|
|
446
|
+
|
|
447
|
+
export interface SwarmInfo {
|
|
448
|
+
NodeID: string;
|
|
449
|
+
LocalNodeState: "inactive" | "pending" | "active" | "error" | "locked" | (string & {});
|
|
450
|
+
ControlAvailable: boolean;
|
|
451
|
+
Cluster?: { ID: string; CreatedAt?: string } | null;
|
|
452
|
+
RemoteManagers?: Array<{ NodeID: string; Addr: string }> | null;
|
|
453
|
+
Managers?: number;
|
|
454
|
+
Nodes?: number;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export type ContainerStatus =
|
|
458
|
+
| "created"
|
|
459
|
+
| "running"
|
|
460
|
+
| "paused"
|
|
461
|
+
| "restarting"
|
|
462
|
+
| "removing"
|
|
463
|
+
| "exited"
|
|
464
|
+
| "dead";
|
|
465
|
+
|
|
466
|
+
export interface Container {
|
|
467
|
+
Id: string;
|
|
468
|
+
Image: string;
|
|
469
|
+
Name?: string;
|
|
470
|
+
State: {
|
|
471
|
+
Status: ContainerStatus;
|
|
472
|
+
/** Present when the container has a healthcheck. */
|
|
473
|
+
Health?: { Status: "starting" | "healthy" | "unhealthy" };
|
|
474
|
+
};
|
|
475
|
+
Created: string;
|
|
476
|
+
Config: {
|
|
477
|
+
Image: string;
|
|
478
|
+
Cmd: string[] | null;
|
|
479
|
+
Env: string[] | null;
|
|
480
|
+
User?: string;
|
|
481
|
+
StopSignal?: string;
|
|
482
|
+
Labels: Record<string, string> | null;
|
|
483
|
+
StopTimeout?: number;
|
|
484
|
+
Healthcheck?: {
|
|
485
|
+
Test: string[] | null;
|
|
486
|
+
Interval?: number;
|
|
487
|
+
Timeout?: number;
|
|
488
|
+
Retries?: number;
|
|
489
|
+
StartPeriod?: number;
|
|
490
|
+
StartInterval?: number;
|
|
491
|
+
} | null;
|
|
492
|
+
};
|
|
493
|
+
HostConfig: {
|
|
494
|
+
PortBindings: Record<string, Array<{ HostIp: string; HostPort: string }> | null> | null;
|
|
495
|
+
Binds: string[] | null;
|
|
496
|
+
ExtraHosts: string[] | null;
|
|
497
|
+
RestartPolicy: { Name: string; MaximumRetryCount: number };
|
|
498
|
+
AutoRemove: boolean;
|
|
499
|
+
NetworkMode?: string;
|
|
500
|
+
CapAdd?: string[] | null;
|
|
501
|
+
CapDrop?: string[] | null;
|
|
502
|
+
Memory?: number;
|
|
503
|
+
MemorySwap?: number;
|
|
504
|
+
MemoryReservation?: number;
|
|
505
|
+
NanoCpus?: number;
|
|
506
|
+
PidsLimit?: number;
|
|
507
|
+
ReadonlyRootfs?: boolean;
|
|
508
|
+
SecurityOpt?: string[] | null;
|
|
509
|
+
UsernsMode?: string;
|
|
510
|
+
GroupAdd?: string[] | null;
|
|
511
|
+
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }> | null;
|
|
512
|
+
LogConfig?: { Type: string; Config: Record<string, string> | null };
|
|
513
|
+
OomScoreAdj?: number;
|
|
514
|
+
Init?: boolean | null;
|
|
515
|
+
Tmpfs?: Record<string, string> | null;
|
|
516
|
+
Devices?: Array<{
|
|
517
|
+
PathOnHost: string;
|
|
518
|
+
PathInContainer: string;
|
|
519
|
+
CgroupPermissions: string;
|
|
520
|
+
}> | null;
|
|
521
|
+
};
|
|
522
|
+
AppArmorProfile?: string;
|
|
523
|
+
NetworkSettings: {
|
|
524
|
+
Networks: Record<string, { NetworkID: string; Aliases: string[] | null }> | null;
|
|
525
|
+
Ports?: Record<string, Array<{ HostIp: string; HostPort: string }> | null> | null;
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface Image {
|
|
530
|
+
Id: string;
|
|
531
|
+
Created?: string;
|
|
532
|
+
RepoTags?: string[] | null;
|
|
533
|
+
RepoDigests?: string[] | null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export interface Volume {
|
|
537
|
+
CreatedAt: string;
|
|
538
|
+
Driver: string;
|
|
539
|
+
Labels: Record<string, string> | null;
|
|
540
|
+
Mountpoint: string;
|
|
541
|
+
Name: string;
|
|
542
|
+
Options: Record<string, string> | null;
|
|
543
|
+
Scope: string;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export interface Context {
|
|
547
|
+
Name: string;
|
|
548
|
+
Metadata?: { Description?: string };
|
|
549
|
+
Endpoints?: { docker?: string };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export interface Network {
|
|
553
|
+
Name: string;
|
|
554
|
+
Id: string;
|
|
555
|
+
Created: string;
|
|
556
|
+
Scope: string;
|
|
557
|
+
Driver: string;
|
|
558
|
+
EnableIPv6: boolean;
|
|
559
|
+
Labels: Record<string, string> | null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export interface CommandOutput {
|
|
564
|
+
exitCode: ChildProcessSpawner.ExitCode;
|
|
565
|
+
stdout: string;
|
|
566
|
+
stderr: string;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export interface DockerOptions {
|
|
570
|
+
/**
|
|
571
|
+
* The Docker-compatible CLI to run, e.g. `"podman"`. The `DOCKER_BIN`
|
|
572
|
+
* environment variable, when set, overrides it (per machine or CI).
|
|
573
|
+
* @default "docker"
|
|
574
|
+
*/
|
|
575
|
+
bin?: string;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Extra environment for every Docker CLI invocation, layered over the
|
|
580
|
+
* process environment (per-call variables still win). Scope it with
|
|
581
|
+
* `Effect.provideService(DockerEnvironment, { BUILDX_BUILDER: "ci" })` to
|
|
582
|
+
* target a buildx builder or Docker context without mutating `process.env`.
|
|
583
|
+
* @default {}
|
|
584
|
+
*/
|
|
585
|
+
export const DockerEnvironment = Context.Reference<Record<string, string>>(
|
|
586
|
+
"alchemy/Docker/DockerEnvironment",
|
|
587
|
+
{ defaultValue: () => ({}) },
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
/** The Docker CLI client, running `options.bin` (`DOCKER_BIN` overrides). */
|
|
591
|
+
export const dockerLive = (options: DockerOptions = {}) =>
|
|
592
|
+
Layer.effect(Docker, makeDocker(options));
|
|
593
|
+
|
|
594
|
+
const makeDocker = (options: DockerOptions) =>
|
|
595
|
+
Effect.gen(function* () {
|
|
596
|
+
const fs = yield* FileSystem.FileSystem;
|
|
597
|
+
const path = yield* Path.Path;
|
|
598
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
599
|
+
const bin = yield* Config.String("DOCKER_BIN").pipe(
|
|
600
|
+
Effect.orElseSucceed(() => options.bin ?? "docker"),
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const run = (
|
|
604
|
+
args: Array<string>,
|
|
605
|
+
env?: Record<string, string>,
|
|
606
|
+
tap: (
|
|
607
|
+
stream: Stream.Stream<string, PlatformError, never>,
|
|
608
|
+
) => Stream.Stream<string, PlatformError, never> = Stream.tap(Effect.logDebug),
|
|
609
|
+
) =>
|
|
610
|
+
Effect.gen(function* () {
|
|
611
|
+
const ambient = yield* DockerEnvironment;
|
|
612
|
+
return yield* spawner.spawn(
|
|
613
|
+
ChildProcess.make(bin, args, {
|
|
614
|
+
stdin: "ignore",
|
|
615
|
+
stdout: "pipe",
|
|
616
|
+
stderr: "pipe",
|
|
617
|
+
detached: false,
|
|
618
|
+
env: { ...ambient, ...env },
|
|
619
|
+
extendEnv: true,
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
}).pipe(
|
|
623
|
+
Effect.flatMap((child) =>
|
|
624
|
+
Effect.all(
|
|
625
|
+
{
|
|
626
|
+
exitCode: child.exitCode,
|
|
627
|
+
stdout: child.stdout.pipe(
|
|
628
|
+
Stream.decodeText,
|
|
629
|
+
tap,
|
|
630
|
+
Stream.mkString,
|
|
631
|
+
Effect.map((stdout) => stdout.trim()),
|
|
632
|
+
),
|
|
633
|
+
stderr: child.stderr.pipe(
|
|
634
|
+
Stream.decodeText,
|
|
635
|
+
tap,
|
|
636
|
+
Stream.mkString,
|
|
637
|
+
Effect.map((stderr) => stderr.trim()),
|
|
638
|
+
),
|
|
639
|
+
},
|
|
640
|
+
{ concurrency: "unbounded" },
|
|
641
|
+
),
|
|
642
|
+
),
|
|
643
|
+
Effect.mapError((error) =>
|
|
644
|
+
systemError({
|
|
645
|
+
_tag: "Unknown",
|
|
646
|
+
args,
|
|
647
|
+
// Name the CLI: a misconfigured `bin` / DOCKER_BIN fails here.
|
|
648
|
+
description: `Failed to run \`${bin}\`; is it installed and on PATH?`,
|
|
649
|
+
cause: error.reason,
|
|
650
|
+
}),
|
|
651
|
+
),
|
|
652
|
+
Effect.tap((result) => {
|
|
653
|
+
if (result.exitCode === 0) return Effect.void;
|
|
654
|
+
const stderr = result.stderr.replace(/^Error response from daemon: /, "");
|
|
655
|
+
if (stderr.match(/not found|not known|no such/i)) {
|
|
656
|
+
return systemError({ _tag: "NotFound", args, description: stderr });
|
|
657
|
+
}
|
|
658
|
+
if (stderr.match(/already exists/i)) {
|
|
659
|
+
return systemError({ _tag: "AlreadyExists", args, description: stderr });
|
|
660
|
+
}
|
|
661
|
+
return systemError({
|
|
662
|
+
_tag: "Unknown",
|
|
663
|
+
args,
|
|
664
|
+
description: `Command exited with code ${result.exitCode}: ${failureOutput(
|
|
665
|
+
stderr,
|
|
666
|
+
result.stdout,
|
|
667
|
+
)}`,
|
|
668
|
+
});
|
|
669
|
+
}),
|
|
670
|
+
Effect.scoped,
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const runInspect = <T>(args: Array<string>) =>
|
|
674
|
+
run(args).pipe(
|
|
675
|
+
Effect.map((result) => {
|
|
676
|
+
const [item] = JSON.parse(result.stdout) as T[];
|
|
677
|
+
return item;
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
// Merge `credentials` into the ambient auth config: the process's
|
|
682
|
+
// DOCKER_AUTH_CONFIG, overlaid by one provided through DockerEnvironment
|
|
683
|
+
// (as `run` layers DockerEnvironment over the process environment).
|
|
684
|
+
// Merging, not replacing, keeps the user's own registry logins.
|
|
685
|
+
const registryEnvironment = Effect.fn("registryEnvironment")(
|
|
686
|
+
(credentials: RegistryCredentials) =>
|
|
687
|
+
Effect.gen(function* () {
|
|
688
|
+
const value = yield* Config.Redacted("DOCKER_AUTH_CONFIG").pipe(
|
|
689
|
+
Config.withDefault(Redacted.make("{}")),
|
|
690
|
+
);
|
|
691
|
+
const base = yield* decodeRegistryAuthConfig(Redacted.value(value) || "{}");
|
|
692
|
+
const ambient = yield* DockerEnvironment;
|
|
693
|
+
const overlay =
|
|
694
|
+
ambient.DOCKER_AUTH_CONFIG === undefined
|
|
695
|
+
? undefined
|
|
696
|
+
: yield* decodeRegistryAuthConfig(ambient.DOCKER_AUTH_CONFIG || "{}");
|
|
697
|
+
return { auths: { ...base.auths, ...overlay?.auths } };
|
|
698
|
+
}).pipe(
|
|
699
|
+
// Schema diagnostics may include credential input. Do not retain them.
|
|
700
|
+
Effect.mapError(() =>
|
|
701
|
+
systemError({
|
|
702
|
+
_tag: "InvalidData",
|
|
703
|
+
args: ["buildx", "build"],
|
|
704
|
+
description: "Invalid DOCKER_AUTH_CONFIG; expected an auths map.",
|
|
705
|
+
}),
|
|
706
|
+
),
|
|
707
|
+
Effect.map((current) => {
|
|
708
|
+
const password = Redacted.isRedacted(credentials.password)
|
|
709
|
+
? Redacted.value(credentials.password)
|
|
710
|
+
: credentials.password;
|
|
711
|
+
const auth = Base64.encode(`${credentials.username}:${password}`);
|
|
712
|
+
// Preserve Docker's file/helper fallback, contexts, and builders.
|
|
713
|
+
return {
|
|
714
|
+
DOCKER_AUTH_CONFIG: JSON.stringify({
|
|
715
|
+
auths: { ...current.auths, [credentials.server]: { auth } },
|
|
716
|
+
}),
|
|
717
|
+
};
|
|
718
|
+
}),
|
|
719
|
+
),
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// How a build reaches a registry. Buildx 0.26 is the first release
|
|
723
|
+
// embedding Docker CLI 28.3's DOCKER_AUTH_CONFIG credential store, so it
|
|
724
|
+
// can `buildx build --push` straight from BuildKit. Older plugins (e.g.
|
|
725
|
+
// the buildx bundled with Docker Desktop < 4.44) silently ignore the
|
|
726
|
+
// variable, so the image is instead `--load`ed into the local store and
|
|
727
|
+
// published with the isolated-config `push`. Without any buildx plugin
|
|
728
|
+
// the legacy builder always loads, so a plain `image build` suffices.
|
|
729
|
+
// Probed once per Docker service instance.
|
|
730
|
+
const publication = yield* Effect.cached(
|
|
731
|
+
Effect.gen(function* () {
|
|
732
|
+
const version = yield* run(["buildx", "version"]).pipe(Effect.option);
|
|
733
|
+
if (Option.isNone(version)) return "legacy" as const;
|
|
734
|
+
// Numeric template captures can consume version separators as decimals.
|
|
735
|
+
const match =
|
|
736
|
+
/^github\.com\/docker\/buildx v?(\d+)\.(\d+)\.\d+(?:[-+][^\s]+)?(?:\s.*)?$/.exec(
|
|
737
|
+
version.value.stdout,
|
|
738
|
+
);
|
|
739
|
+
if (!match) return "load" as const;
|
|
740
|
+
const major = Number(match[1]);
|
|
741
|
+
const minor = Number(match[2]);
|
|
742
|
+
return major >= 1 || minor >= 26 ? ("export" as const) : ("load" as const);
|
|
743
|
+
}),
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const outputTap = (session?: Pick<ScopedPlanStatusSession, "note">) =>
|
|
747
|
+
session
|
|
748
|
+
? Stream.tapSink(
|
|
749
|
+
Sink.make<string>()(
|
|
750
|
+
flow(
|
|
751
|
+
Stream.splitLines,
|
|
752
|
+
Stream.runForEach((line) => session.note(line, { kind: "output" })),
|
|
753
|
+
),
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
: undefined;
|
|
757
|
+
|
|
758
|
+
// `podman push` prints no `digest:` line, and Podman's RepoDigests can
|
|
759
|
+
// list a source digest under the pushed name, so Podman pushes report the
|
|
760
|
+
// published digest through `--digestfile` instead. Probed once.
|
|
761
|
+
const isPodman = yield* Effect.cached(
|
|
762
|
+
run(["--version"]).pipe(
|
|
763
|
+
Effect.map((result) => /^podman\b/i.test(result.stdout.trim())),
|
|
764
|
+
Effect.orElseSucceed(() => false),
|
|
765
|
+
),
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const push: Docker["Service"]["image"]["push"] = Effect.fn(
|
|
769
|
+
function* (ref, credentials, platform, context, session) {
|
|
770
|
+
// Write the registry credentials directly into an isolated docker config
|
|
771
|
+
// as a plaintext `auths` entry and skip `docker login` entirely.
|
|
772
|
+
//
|
|
773
|
+
// `docker login` is the wrong tool here: on macOS Docker Desktop it routes
|
|
774
|
+
// through the shared `osxkeychain`/`desktop` credential helper *regardless*
|
|
775
|
+
// of an isolated DOCKER_CONFIG, so concurrent deploys either race the system
|
|
776
|
+
// keychain (`The specified item already exists in the keychain (-25299)`) or
|
|
777
|
+
// land the credential in the helper — leaving this isolated config without
|
|
778
|
+
// an `auths` entry, so the subsequent `docker push` fails with "no basic
|
|
779
|
+
// auth credentials". Embedding the base64 `auth` inline (the same thing
|
|
780
|
+
// `docker login` would write when no credsStore is configured) makes each
|
|
781
|
+
// deploy fully self-contained: no credential helper, no keychain, no login
|
|
782
|
+
// race. Only `push` reads this config; `build`/`pull`/`tag` keep using the
|
|
783
|
+
// global docker config (buildx builders, `docker context`, etc. intact).
|
|
784
|
+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-docker-" });
|
|
785
|
+
// The isolated DOCKER_CONFIG holds none of the CLI's contexts. Ask the CLI
|
|
786
|
+
// (same environment as the push) which context it would use, and copy just
|
|
787
|
+
// that context's metadata and TLS material in.
|
|
788
|
+
const selected =
|
|
789
|
+
context ??
|
|
790
|
+
(yield* run(["context", "show"]).pipe(
|
|
791
|
+
Effect.map((result) => result.stdout),
|
|
792
|
+
Effect.orElseSucceed(() => "default"),
|
|
793
|
+
));
|
|
794
|
+
if (selected && selected !== "default") {
|
|
795
|
+
const inspected = yield* runInspect<{
|
|
796
|
+
Storage: { MetadataPath: string; TLSPath: string };
|
|
797
|
+
}>(["context", "inspect", selected]);
|
|
798
|
+
for (const [kind, source] of [
|
|
799
|
+
["meta", inspected.Storage.MetadataPath],
|
|
800
|
+
["tls", inspected.Storage.TLSPath],
|
|
801
|
+
] as const) {
|
|
802
|
+
if (!(yield* fs.exists(source))) continue;
|
|
803
|
+
const target = path.join(dir, "contexts", kind, path.basename(source));
|
|
804
|
+
yield* fs.makeDirectory(path.dirname(target), { recursive: true });
|
|
805
|
+
yield* fs.copy(source, target);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const pushContext = selected && selected !== "default" ? selected : context;
|
|
809
|
+
const config = yield* Effect.sync(() => {
|
|
810
|
+
const password = Redacted.isRedacted(credentials.password)
|
|
811
|
+
? Redacted.value(credentials.password)
|
|
812
|
+
: credentials.password;
|
|
813
|
+
const auth = Buffer.from(`${credentials.username}:${password}`).toString("base64");
|
|
814
|
+
return JSON.stringify({ auths: { [credentials.server]: { auth } } });
|
|
815
|
+
});
|
|
816
|
+
yield* fs.writeFileString(path.join(dir, "config.json"), config);
|
|
817
|
+
const digestFile = path.join(dir, "digest");
|
|
818
|
+
const digestArgs = (yield* isPodman) ? ["--digestfile", digestFile] : [];
|
|
819
|
+
const result =
|
|
820
|
+
platform === undefined
|
|
821
|
+
? yield* run(
|
|
822
|
+
[...formatArgs({ context: pushContext }), "push", ...digestArgs, ref],
|
|
823
|
+
{ DOCKER_CONFIG: dir },
|
|
824
|
+
outputTap(session),
|
|
825
|
+
)
|
|
826
|
+
: yield* run(
|
|
827
|
+
[
|
|
828
|
+
...formatArgs({ context: pushContext }),
|
|
829
|
+
"push",
|
|
830
|
+
"--platform",
|
|
831
|
+
platform,
|
|
832
|
+
...digestArgs,
|
|
833
|
+
ref,
|
|
834
|
+
],
|
|
835
|
+
{ DOCKER_CONFIG: dir },
|
|
836
|
+
outputTap(session),
|
|
837
|
+
).pipe(
|
|
838
|
+
// Engines without the containerd image store reject `--platform`
|
|
839
|
+
// on push; their local tag is already narrowed to the requested
|
|
840
|
+
// platform by `pull --platform`, so a plain push is equivalent.
|
|
841
|
+
Effect.catchIf(
|
|
842
|
+
(error) => /--platform|unknown flag|containerd/i.test(String(error)),
|
|
843
|
+
() =>
|
|
844
|
+
run(
|
|
845
|
+
[...formatArgs({ context: pushContext }), "push", ...digestArgs, ref],
|
|
846
|
+
{ DOCKER_CONFIG: dir },
|
|
847
|
+
outputTap(session),
|
|
848
|
+
),
|
|
849
|
+
),
|
|
850
|
+
);
|
|
851
|
+
if (digestArgs.length === 0) return result;
|
|
852
|
+
const digest = (yield* fs
|
|
853
|
+
.readFileString(digestFile)
|
|
854
|
+
.pipe(Effect.orElseSucceed(() => ""))).trim();
|
|
855
|
+
// Report it the way `docker push` does, so callers parse one format.
|
|
856
|
+
return digest ? { ...result, stdout: `${result.stdout}\ndigest: ${digest}` } : result;
|
|
857
|
+
},
|
|
858
|
+
Effect.scoped,
|
|
859
|
+
Effect.mapError(classifyDockerRegistryError),
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
return Docker.of({
|
|
863
|
+
run,
|
|
864
|
+
materialize: Effect.fn((options) =>
|
|
865
|
+
Effect.forEach(
|
|
866
|
+
[...options.files, { path: "Dockerfile", content: options.dockerfile }],
|
|
867
|
+
(file) => {
|
|
868
|
+
const fullPath = path.join(options.context, file.path);
|
|
869
|
+
return fs
|
|
870
|
+
.makeDirectory(path.dirname(fullPath), { recursive: true })
|
|
871
|
+
.pipe(
|
|
872
|
+
Effect.andThen(
|
|
873
|
+
typeof file.content === "string"
|
|
874
|
+
? fs.writeFileString(fullPath, file.content)
|
|
875
|
+
: fs.writeFile(fullPath, file.content),
|
|
876
|
+
),
|
|
877
|
+
);
|
|
878
|
+
},
|
|
879
|
+
{ concurrency: "unbounded" },
|
|
880
|
+
),
|
|
881
|
+
),
|
|
882
|
+
container: {
|
|
883
|
+
create: ({ image, env, command, context, ...options }) =>
|
|
884
|
+
// `--env KEY` (name-only) keeps values off CLI arguments; Docker
|
|
885
|
+
// copies each value from the CLI process environment, so the
|
|
886
|
+
// normalized container env must be passed to the child process.
|
|
887
|
+
run(
|
|
888
|
+
[
|
|
889
|
+
...formatArgs({ context }),
|
|
890
|
+
"container",
|
|
891
|
+
"create",
|
|
892
|
+
...formatArgs({ ...options, env: env ? Object.keys(env) : undefined }),
|
|
893
|
+
image,
|
|
894
|
+
...(command ?? []),
|
|
895
|
+
],
|
|
896
|
+
env,
|
|
897
|
+
),
|
|
898
|
+
inspect: (name, context) =>
|
|
899
|
+
runInspect<Docker.Container>([...formatArgs({ context }), "container", "inspect", name]),
|
|
900
|
+
remove: (name, force, context) =>
|
|
901
|
+
run([...formatArgs({ context }), "container", "rm", name, ...(force ? ["-f"] : [])]),
|
|
902
|
+
start: (name, context) => run([...formatArgs({ context }), "container", "start", name]),
|
|
903
|
+
stop: (name, context) => run([...formatArgs({ context }), "container", "stop", name]),
|
|
904
|
+
},
|
|
905
|
+
image: {
|
|
906
|
+
build: Effect.fn("Docker.image.build")(function* (
|
|
907
|
+
{ context: buildContext, engineContext, args, credentials, secrets, ...options },
|
|
908
|
+
session,
|
|
909
|
+
registry,
|
|
910
|
+
) {
|
|
911
|
+
const tap = outputTap(session);
|
|
912
|
+
const secret = buildSecrets(secrets);
|
|
913
|
+
const buildArgs = [buildContext, ...formatArgs(options), ...secret.args, ...(args ?? [])];
|
|
914
|
+
const engine = formatArgs({ context: engineContext });
|
|
915
|
+
if (registry === undefined) {
|
|
916
|
+
return yield* run(
|
|
917
|
+
[...engine, "image", "build", ...buildArgs],
|
|
918
|
+
{
|
|
919
|
+
...(credentials ? yield* registryEnvironment(credentials) : undefined),
|
|
920
|
+
...secret.env,
|
|
921
|
+
},
|
|
922
|
+
tap,
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
const mode = yield* publication;
|
|
926
|
+
if (mode === "export") {
|
|
927
|
+
// Export straight from BuildKit to the registry; the image never
|
|
928
|
+
// round-trips through the local image store.
|
|
929
|
+
const env = yield* registryEnvironment(registry);
|
|
930
|
+
return yield* run(
|
|
931
|
+
[...engine, "buildx", "build", "--push", ...buildArgs],
|
|
932
|
+
{ ...env, ...secret.env },
|
|
933
|
+
tap,
|
|
934
|
+
).pipe(Effect.mapError(classifyDockerRegistryError));
|
|
935
|
+
}
|
|
936
|
+
// Older Buildx ignores DOCKER_AUTH_CONFIG, so a `--push` export
|
|
937
|
+
// would fail with "no basic auth credentials". Build into the local
|
|
938
|
+
// store and publish with the isolated-config `push` instead.
|
|
939
|
+
// `--load` matters for non-loading builders (docker-container):
|
|
940
|
+
// without it the result stays in the build cache and `push` sees no
|
|
941
|
+
// such tag.
|
|
942
|
+
yield* run(
|
|
943
|
+
[
|
|
944
|
+
...engine,
|
|
945
|
+
...(mode === "load" ? ["buildx", "build", "--load"] : ["image", "build"]),
|
|
946
|
+
...buildArgs,
|
|
947
|
+
],
|
|
948
|
+
secret.env,
|
|
949
|
+
tap,
|
|
950
|
+
);
|
|
951
|
+
const [tag, ...tags] =
|
|
952
|
+
typeof options.tag === "string" ? ([options.tag] as const) : options.tag;
|
|
953
|
+
return yield* push(tag, registry, options.platform, engineContext, session).pipe(
|
|
954
|
+
Effect.tap(() =>
|
|
955
|
+
Effect.forEach(tags, (tag) =>
|
|
956
|
+
push(tag, registry, options.platform, engineContext, session),
|
|
957
|
+
),
|
|
958
|
+
),
|
|
959
|
+
);
|
|
960
|
+
}),
|
|
961
|
+
pull: (ref, platform, context, session) =>
|
|
962
|
+
run(
|
|
963
|
+
[
|
|
964
|
+
...formatArgs({ context }),
|
|
965
|
+
"image",
|
|
966
|
+
"pull",
|
|
967
|
+
ref,
|
|
968
|
+
...(platform ? ["--platform", platform] : []),
|
|
969
|
+
],
|
|
970
|
+
undefined,
|
|
971
|
+
outputTap(session),
|
|
972
|
+
),
|
|
973
|
+
inspect: (ref, context) =>
|
|
974
|
+
runInspect<Docker.Image>([...formatArgs({ context }), "image", "inspect", ref]),
|
|
975
|
+
remove: (ref, force, context) =>
|
|
976
|
+
run([
|
|
977
|
+
...formatArgs({ context }),
|
|
978
|
+
"image",
|
|
979
|
+
"rm",
|
|
980
|
+
...(Array.isArray(ref) ? ref : [ref]),
|
|
981
|
+
...(force ? ["-f"] : []),
|
|
982
|
+
]),
|
|
983
|
+
tag: (source, target, context) =>
|
|
984
|
+
run([...formatArgs({ context }), "image", "tag", source, target]),
|
|
985
|
+
push,
|
|
986
|
+
},
|
|
987
|
+
volume: {
|
|
988
|
+
create: ({ context, ...options }) =>
|
|
989
|
+
run([...formatArgs({ context }), "volume", "create", ...formatArgs(options)]),
|
|
990
|
+
remove: (name, context) => run([...formatArgs({ context }), "volume", "rm", name]),
|
|
991
|
+
inspect: (name, context) =>
|
|
992
|
+
runInspect<Docker.Volume>([...formatArgs({ context }), "volume", "inspect", name]),
|
|
993
|
+
},
|
|
994
|
+
context: {
|
|
995
|
+
create: ({ name, description, docker }) =>
|
|
996
|
+
run(["context", "create", name, ...formatArgs({ description, docker })]),
|
|
997
|
+
update: ({ name, description, docker }) =>
|
|
998
|
+
run(["context", "update", name, ...formatArgs({ description, docker })]),
|
|
999
|
+
inspect: (name) => runInspect<Docker.Context>(["context", "inspect", name]),
|
|
1000
|
+
remove: (name, force) => run(["context", "rm", ...(force ? ["-f"] : []), name]),
|
|
1001
|
+
},
|
|
1002
|
+
network: {
|
|
1003
|
+
create: ({ name, driver, ipv6, label, context }) =>
|
|
1004
|
+
run([
|
|
1005
|
+
...formatArgs({ context }),
|
|
1006
|
+
"network",
|
|
1007
|
+
"create",
|
|
1008
|
+
name,
|
|
1009
|
+
...formatArgs({ driver, ipv6, label }),
|
|
1010
|
+
]),
|
|
1011
|
+
connect: ({ network, container, alias, context }) =>
|
|
1012
|
+
run([
|
|
1013
|
+
...formatArgs({ context }),
|
|
1014
|
+
"network",
|
|
1015
|
+
"connect",
|
|
1016
|
+
network,
|
|
1017
|
+
container,
|
|
1018
|
+
...(alias ? alias.flatMap((a) => ["--alias", a]) : []),
|
|
1019
|
+
]),
|
|
1020
|
+
disconnect: ({ network, container, context }) =>
|
|
1021
|
+
run([...formatArgs({ context }), "network", "disconnect", network, container]),
|
|
1022
|
+
inspect: (name, context) =>
|
|
1023
|
+
runInspect<Docker.Network>([...formatArgs({ context }), "network", "inspect", name]),
|
|
1024
|
+
remove: (id, context) => run([...formatArgs({ context }), "network", "rm", id]),
|
|
1025
|
+
},
|
|
1026
|
+
swarm: {
|
|
1027
|
+
init: ({ context, ...options }) =>
|
|
1028
|
+
run([...formatArgs({ context }), "swarm", "init", ...formatArgs(options)]),
|
|
1029
|
+
info: (context) =>
|
|
1030
|
+
run([...formatArgs({ context }), "info", "--format", "{{json .Swarm}}"]).pipe(
|
|
1031
|
+
Effect.map((result) => JSON.parse(result.stdout) as Docker.SwarmInfo),
|
|
1032
|
+
),
|
|
1033
|
+
leave: (force, context) =>
|
|
1034
|
+
run([...formatArgs({ context }), "swarm", "leave", ...(force ? ["--force"] : [])]),
|
|
1035
|
+
},
|
|
1036
|
+
service: {
|
|
1037
|
+
create: ({ context, image, command, args, ...options }) =>
|
|
1038
|
+
run([
|
|
1039
|
+
...formatArgs({ context }),
|
|
1040
|
+
"service",
|
|
1041
|
+
"create",
|
|
1042
|
+
...formatArgs({ ...options }),
|
|
1043
|
+
image,
|
|
1044
|
+
...(command ?? []),
|
|
1045
|
+
...(args ?? []),
|
|
1046
|
+
]),
|
|
1047
|
+
update: ({ context, id, ...options }) =>
|
|
1048
|
+
run([
|
|
1049
|
+
...formatArgs({ context }),
|
|
1050
|
+
"service",
|
|
1051
|
+
"update",
|
|
1052
|
+
"--detach=false",
|
|
1053
|
+
...formatArgs({ ...options }),
|
|
1054
|
+
id,
|
|
1055
|
+
]),
|
|
1056
|
+
inspect: (id, context) =>
|
|
1057
|
+
runInspect([...formatArgs({ context }), "service", "inspect", id]),
|
|
1058
|
+
remove: (id, context) => run([...formatArgs({ context }), "service", "rm", id]),
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
/** The Docker CLI client, running `docker` (or `DOCKER_BIN`). */
|
|
1064
|
+
export const DockerLive = dockerLive();
|
|
1065
|
+
|
|
1066
|
+
export const dockerContextName = (context: Docker.ContextRef | undefined): string | undefined => {
|
|
1067
|
+
const value = typeof context === "string" ? context : context?.name;
|
|
1068
|
+
const normalized = value?.trim();
|
|
1069
|
+
return normalized && normalized.length > 0 ? normalized : undefined;
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Resolve an {@link Docker.EngineRef} to a context name. A `Docker.Swarm`
|
|
1074
|
+
* reference (narrowed by its `nodeId` attribute) contributes the context the
|
|
1075
|
+
* swarm was initialized on.
|
|
1076
|
+
*/
|
|
1077
|
+
export const dockerEngineContextName = (ref: Docker.EngineRef | undefined): string | undefined => {
|
|
1078
|
+
if (typeof ref === "object" && ref !== null && "nodeId" in ref) {
|
|
1079
|
+
return dockerContextName((ref as { context?: string }).context);
|
|
1080
|
+
}
|
|
1081
|
+
return dockerContextName(ref as Docker.ContextRef | undefined);
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
export const dockerPhysicalName = (
|
|
1085
|
+
id: string,
|
|
1086
|
+
props: { name?: string } | undefined,
|
|
1087
|
+
instanceId: string,
|
|
1088
|
+
// Swarm service names are capped at 63 characters; other Docker object
|
|
1089
|
+
// names accept much longer values.
|
|
1090
|
+
maxLength = 128,
|
|
1091
|
+
) =>
|
|
1092
|
+
props?.name
|
|
1093
|
+
? Effect.succeed(props.name)
|
|
1094
|
+
: createPhysicalName({ id, instanceId, maxLength, lowercase: true });
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* A failing `docker` command splits its diagnosis over both streams: the
|
|
1098
|
+
* builder writes the step log to one and the reason it stopped to the other,
|
|
1099
|
+
* and which carries which depends on the builder, the progress mode and
|
|
1100
|
+
* whether the output is a terminal. A build that fails inside a `RUN` step
|
|
1101
|
+
* therefore reports nothing but its exit code when only `stderr` is kept.
|
|
1102
|
+
* Keep both, the reason first, so the error says why the build failed.
|
|
1103
|
+
*/
|
|
1104
|
+
const failureOutput = (stderr: string, stdout: string) => {
|
|
1105
|
+
const output = [stderr, stdout].filter((text) => text.length > 0);
|
|
1106
|
+
return output.length > 0 ? output.join("\n") : "the command wrote no output.";
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
/** Constructs a PlatformError from a command execution result. */
|
|
1110
|
+
const systemError = (input: {
|
|
1111
|
+
_tag: SystemErrorTag;
|
|
1112
|
+
args: Array<string>;
|
|
1113
|
+
description?: string;
|
|
1114
|
+
cause?: unknown;
|
|
1115
|
+
}) =>
|
|
1116
|
+
new PlatformError(
|
|
1117
|
+
new SystemError({
|
|
1118
|
+
_tag: input._tag,
|
|
1119
|
+
module: "Docker",
|
|
1120
|
+
method: input.args.slice(0, 2).join("."),
|
|
1121
|
+
pathOrDescriptor: input.args.join(" "),
|
|
1122
|
+
description: input.description,
|
|
1123
|
+
cause: input.cause,
|
|
1124
|
+
}),
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* `--secret id=<id>,env=<var>` per secret, with each value under a generated
|
|
1129
|
+
* variable name: an id is the Dockerfile author's choice and may collide with
|
|
1130
|
+
* the environment Docker itself reads (`PATH`, `DOCKER_HOST`).
|
|
1131
|
+
*/
|
|
1132
|
+
const buildSecrets = (secrets: Record<string, string | Redacted.Redacted<string>> | undefined) => {
|
|
1133
|
+
const args: Array<string> = [];
|
|
1134
|
+
const env: Record<string, string> = {};
|
|
1135
|
+
Object.entries(secrets ?? {}).forEach(([id, value], index) => {
|
|
1136
|
+
const variable = `ALCHEMY_BUILD_SECRET_${index}`;
|
|
1137
|
+
args.push("--secret", `id=${id},env=${variable}`);
|
|
1138
|
+
env[variable] = Redacted.isRedacted(value) ? Redacted.value(value) : value;
|
|
1139
|
+
});
|
|
1140
|
+
return { args, env };
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
/** Formats a set of options into a list of command-line arguments. */
|
|
1144
|
+
const formatArgs = (
|
|
1145
|
+
options: Record<
|
|
1146
|
+
string,
|
|
1147
|
+
boolean | string | number | undefined | Record<string, string> | Array<string>
|
|
1148
|
+
>,
|
|
1149
|
+
) => {
|
|
1150
|
+
const args: Array<string> = [];
|
|
1151
|
+
for (const [key, value] of Object.entries(options)) {
|
|
1152
|
+
// `0` is meaningful (`--replicas 0`, `--update-parallelism 0`), so only
|
|
1153
|
+
// absent, `false` and empty values are omitted.
|
|
1154
|
+
if (value === undefined || value === false || value === "") continue;
|
|
1155
|
+
const prefix = key.length > 1 ? `--${key}` : `-${key}`;
|
|
1156
|
+
if (value === true) {
|
|
1157
|
+
args.push(prefix);
|
|
1158
|
+
} else if (typeof value === "string") {
|
|
1159
|
+
args.push(prefix, value);
|
|
1160
|
+
} else if (typeof value === "number") {
|
|
1161
|
+
args.push(prefix, String(value));
|
|
1162
|
+
} else if (Array.isArray(value)) {
|
|
1163
|
+
for (const item of value) {
|
|
1164
|
+
args.push(prefix, item);
|
|
1165
|
+
}
|
|
1166
|
+
} else if (value !== null && typeof value === "object") {
|
|
1167
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1168
|
+
args.push(prefix, `${k}=${v}`);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return args;
|
|
1173
|
+
};
|