@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.
Files changed (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +46 -4
  3. package/lib/Bootstrap.d.ts +7 -0
  4. package/lib/Bootstrap.d.ts.map +1 -0
  5. package/lib/Bootstrap.js +25 -0
  6. package/lib/Bootstrap.js.map +1 -0
  7. package/lib/BuildHash.d.ts +56 -0
  8. package/lib/BuildHash.d.ts.map +1 -0
  9. package/lib/BuildHash.js +172 -0
  10. package/lib/BuildHash.js.map +1 -0
  11. package/lib/Container.d.ts +450 -0
  12. package/lib/Container.d.ts.map +1 -0
  13. package/lib/Container.js +745 -0
  14. package/lib/Container.js.map +1 -0
  15. package/lib/Context.d.ts +86 -0
  16. package/lib/Context.d.ts.map +1 -0
  17. package/lib/Context.js +146 -0
  18. package/lib/Context.js.map +1 -0
  19. package/lib/Docker.d.ts +527 -0
  20. package/lib/Docker.d.ts.map +1 -0
  21. package/lib/Docker.js +437 -0
  22. package/lib/Docker.js.map +1 -0
  23. package/lib/Dockerfile.d.ts +72 -0
  24. package/lib/Dockerfile.d.ts.map +1 -0
  25. package/lib/Dockerfile.js +12 -0
  26. package/lib/Dockerfile.js.map +1 -0
  27. package/lib/HealthcheckCommand.d.ts +17 -0
  28. package/lib/HealthcheckCommand.d.ts.map +1 -0
  29. package/lib/HealthcheckCommand.js +33 -0
  30. package/lib/HealthcheckCommand.js.map +1 -0
  31. package/lib/Image.d.ts +179 -0
  32. package/lib/Image.d.ts.map +1 -0
  33. package/lib/Image.js +210 -0
  34. package/lib/Image.js.map +1 -0
  35. package/lib/ImageSource.d.ts +288 -0
  36. package/lib/ImageSource.d.ts.map +1 -0
  37. package/lib/ImageSource.js +498 -0
  38. package/lib/ImageSource.js.map +1 -0
  39. package/lib/Network.d.ts +70 -0
  40. package/lib/Network.d.ts.map +1 -0
  41. package/lib/Network.js +111 -0
  42. package/lib/Network.js.map +1 -0
  43. package/lib/Providers.d.ts +21 -0
  44. package/lib/Providers.d.ts.map +1 -0
  45. package/lib/Providers.js +26 -0
  46. package/lib/Providers.js.map +1 -0
  47. package/lib/Registry.d.ts +47 -0
  48. package/lib/Registry.d.ts.map +1 -0
  49. package/lib/Registry.js +67 -0
  50. package/lib/Registry.js.map +1 -0
  51. package/lib/RegistryError.d.ts +21 -0
  52. package/lib/RegistryError.d.ts.map +1 -0
  53. package/lib/RegistryError.js +28 -0
  54. package/lib/RegistryError.js.map +1 -0
  55. package/lib/RemoteImage.d.ts +118 -0
  56. package/lib/RemoteImage.d.ts.map +1 -0
  57. package/lib/RemoteImage.js +164 -0
  58. package/lib/RemoteImage.js.map +1 -0
  59. package/lib/Service.d.ts +499 -0
  60. package/lib/Service.d.ts.map +1 -0
  61. package/lib/Service.js +730 -0
  62. package/lib/Service.js.map +1 -0
  63. package/lib/ServiceImage.d.ts +78 -0
  64. package/lib/ServiceImage.d.ts.map +1 -0
  65. package/lib/ServiceImage.js +162 -0
  66. package/lib/ServiceImage.js.map +1 -0
  67. package/lib/Swarm.d.ts +119 -0
  68. package/lib/Swarm.d.ts.map +1 -0
  69. package/lib/Swarm.js +148 -0
  70. package/lib/Swarm.js.map +1 -0
  71. package/lib/Volume.d.ts +94 -0
  72. package/lib/Volume.d.ts.map +1 -0
  73. package/lib/Volume.js +133 -0
  74. package/lib/Volume.js.map +1 -0
  75. package/lib/index.d.ts +14 -0
  76. package/lib/index.d.ts.map +1 -0
  77. package/lib/index.js +13 -0
  78. package/lib/index.js.map +1 -0
  79. package/package.json +58 -3
  80. package/src/Bootstrap.ts +51 -0
  81. package/src/BuildHash.ts +265 -0
  82. package/src/Container.ts +1118 -0
  83. package/src/Context.ts +215 -0
  84. package/src/Docker.ts +1173 -0
  85. package/src/Dockerfile.ts +81 -0
  86. package/src/HealthcheckCommand.ts +35 -0
  87. package/src/Image.ts +322 -0
  88. package/src/ImageSource.ts +842 -0
  89. package/src/Network.ts +169 -0
  90. package/src/Providers.ts +46 -0
  91. package/src/Registry.ts +91 -0
  92. package/src/RegistryError.ts +48 -0
  93. package/src/RemoteImage.ts +255 -0
  94. package/src/Service.ts +1264 -0
  95. package/src/ServiceImage.ts +252 -0
  96. package/src/Swarm.ts +221 -0
  97. package/src/Volume.ts +193 -0
  98. package/src/index.ts +17 -0
package/lib/Service.js ADDED
@@ -0,0 +1,730 @@
1
+ import { Unowned } from "alchemy/AdoptPolicy";
2
+ import { deepEqual, isResolved } from "alchemy/Diff";
3
+ import { Platform } from "alchemy/Platform";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { createContainerRuntimeContext, } from "alchemy/Runtime/Process";
6
+ import { Stack } from "alchemy/Stack";
7
+ import { createInternalTags, hasAlchemyTags } from "alchemy/Tags";
8
+ import * as Data from "effect/Data";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Equal from "effect/Equal";
11
+ import * as Redacted from "effect/Redacted";
12
+ import * as Schedule from "effect/Schedule";
13
+ import { Docker, dockerEngineContextName, dockerPhysicalName } from "./Docker.js";
14
+ import { healthcheckCommand, isHealthcheckDisabled } from "./HealthcheckCommand.js";
15
+ import { makeServiceImage } from "./ServiceImage.js";
16
+ /**
17
+ * A Docker Swarm service: N replicas of a container kept alive by the swarm,
18
+ * deployed through the active (or a named) Docker context.
19
+ *
20
+ * The target engine must be a swarm manager — `Service` wraps
21
+ * `docker service`, swarm mode's orchestration API. Declare the swarm with
22
+ * `Docker.Swarm` and pass it as `context` so the service deploys after the
23
+ * swarm exists; for a plain single container on a non-swarm daemon use
24
+ * `Docker.Container` instead.
25
+ *
26
+ * The service's image comes from one of two sources:
27
+ *
28
+ * - `image` — run a pre-built reference (a registry ref, or a `Docker.Image`
29
+ * / `Docker.RemoteImage` resource).
30
+ * - `main` — bundle an inline Effect program into a generated bun image,
31
+ * built directly against the service's Docker context. The impl returns
32
+ * `{ fetch }` and may register background loops via `ServerHost.run` —
33
+ * the same effectful platform shape as `AWS.ECS.Service`.
34
+ *
35
+ * The bundled image is content-addressed and only rebuilt when the program
36
+ * (or its generated Dockerfile) changes. It is built on the target engine's
37
+ * local store — single-node swarms run it as-is; multi-node swarms need the
38
+ * image on a registry every node can reach (build with `Docker.Image` +
39
+ * `registry` and pass the pushed ref as `image` instead).
40
+ *
41
+ * Only replicated services are supported. Changes to the image, replicas,
42
+ * environment, labels, networks, ports, mounts, secrets, configs, constraints,
43
+ * healthcheck, and rollout or restart policies are applied in place with
44
+ * `docker service update`, so swarm rolls tasks over following `updateConfig`.
45
+ * An update that swarm rolls back or pauses fails the deploy with
46
+ * `DockerServiceUpdateFailed`. Changing the name, context, command, args,
47
+ * endpoint mode, placement preferences, or read-only root filesystem, or
48
+ * removing a policy setting, replaces the service (delete-then-create).
49
+ * ### Creating Services
50
+ * **Example:** Replicated Nginx
51
+ * ```typescript
52
+ * const swarm = yield* Docker.Swarm("swarm");
53
+ * const web = yield* Docker.Service("web", {
54
+ * context: swarm,
55
+ * image: "nginx:alpine",
56
+ * replicas: 3,
57
+ * ports: [{ external: 8080, internal: 80 }],
58
+ * });
59
+ * ```
60
+ *
61
+ * **Example:** Run a Built Image
62
+ * ```typescript
63
+ * const image = yield* Docker.Image("app-image", {
64
+ * build: { context: "./app" },
65
+ * });
66
+ * const app = yield* Docker.Service("app", {
67
+ * context: swarm,
68
+ * image,
69
+ * replicas: 2,
70
+ * });
71
+ * ```
72
+ *
73
+ * ### Effectful Services
74
+ * **Example:** Inline Effect Server
75
+ * ```typescript
76
+ * const swarm = yield* Docker.Swarm("swarm");
77
+ * const api = yield* Docker.Service(
78
+ * "Api",
79
+ * {
80
+ * context: swarm,
81
+ * main: import.meta.url,
82
+ * port: 3000,
83
+ * ports: [{ external: 8080, internal: 3000 }],
84
+ * replicas: 2,
85
+ * },
86
+ * Effect.gen(function* () {
87
+ * return {
88
+ * fetch: Effect.gen(function* () {
89
+ * return yield* HttpServerResponse.json({ ok: true });
90
+ * }),
91
+ * };
92
+ * }),
93
+ * );
94
+ * ```
95
+ *
96
+ * **Example:** Background Loops with ServerHost
97
+ * ```typescript
98
+ * // Class props may be an Effect, so the service can yield the swarm it
99
+ * // deploys into (declared once at module level).
100
+ * const Swarm = Docker.Swarm("swarm");
101
+ *
102
+ * export default class Worker extends Docker.Service<Worker>()(
103
+ * "Worker",
104
+ * Effect.gen(function* () {
105
+ * const swarm = yield* Swarm;
106
+ * return { context: swarm, main: import.meta.url, port: 3000 };
107
+ * }),
108
+ * Effect.gen(function* () {
109
+ * const host = yield* ServerHost;
110
+ * yield* host.run(
111
+ * pollQueue.pipe(Effect.repeat(Schedule.spaced("5 seconds")), Effect.asVoid),
112
+ * );
113
+ * return {
114
+ * fetch: Effect.succeed(HttpServerResponse.text("ok")),
115
+ * };
116
+ * }),
117
+ * ) {}
118
+ * ```
119
+ *
120
+ * ### Bundling & Tree-shaking
121
+ * `main` is bundled with rolldown at deploy time. Unused code is
122
+ * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
123
+ * pure so unused parts prune more aggressively. Your app is not
124
+ * marked pure.
125
+ *
126
+ * **Example:** Mark additional packages as pure
127
+ * Only list packages with no top-level side effects.
128
+ * ```typescript
129
+ * {
130
+ * main: import.meta.url,
131
+ * build: {
132
+ * pure: { packages: ["my-lib", "@my-scope/*"] },
133
+ * },
134
+ * }
135
+ * ```
136
+ *
137
+ * **Example:** Turn it off
138
+ * ```typescript
139
+ * {
140
+ * main: import.meta.url,
141
+ * build: { pure: false },
142
+ * }
143
+ * ```
144
+ *
145
+ * ### Docker Contexts
146
+ * **Example:** Deploy to a Remote Swarm over SSH
147
+ * ```typescript
148
+ * const vps = yield* Docker.Context("vps", {
149
+ * docker: "host=ssh://deploy@example.com",
150
+ * });
151
+ * const swarm = yield* Docker.Swarm("swarm", {
152
+ * context: vps,
153
+ * advertiseAddr: "10.0.0.1",
154
+ * });
155
+ * const app = yield* Docker.Service("app", {
156
+ * context: swarm,
157
+ * image: "nginx:alpine",
158
+ * replicas: 3,
159
+ * });
160
+ * ```
161
+ *
162
+ * ### Networks & Volumes
163
+ * **Example:** Overlay Network with Aliases
164
+ * ```typescript
165
+ * const network = yield* Docker.Network("app-net", {
166
+ * context: swarm,
167
+ * driver: "overlay",
168
+ * });
169
+ * const db = yield* Docker.Service("db", {
170
+ * context: swarm,
171
+ * image: "postgres:18-alpine",
172
+ * networks: [{ name: network.name, aliases: ["postgres"] }],
173
+ * volumes: [{ hostPath: "pg-data", containerPath: "/var/lib/postgresql/data" }],
174
+ * });
175
+ * ```
176
+ *
177
+ * ### Rollouts & Placement
178
+ * **Example:** Rolling Update with Rollback
179
+ * ```typescript
180
+ * const app = yield* Docker.Service("app", {
181
+ * image: "ghcr.io/acme/app:latest",
182
+ * replicas: 4,
183
+ * updateConfig: {
184
+ * parallelism: 1,
185
+ * delay: "10s",
186
+ * failureAction: "rollback",
187
+ * order: "start-first",
188
+ * },
189
+ * placement: {
190
+ * constraints: ["node.role==worker"],
191
+ * maxReplicasPerNode: 2,
192
+ * },
193
+ * });
194
+ * ```
195
+ *
196
+ * ### Secrets & Configs
197
+ * **Example:** Mount Swarm Secrets
198
+ * ```typescript
199
+ * const app = yield* Docker.Service("app", {
200
+ * image: "ghcr.io/acme/app:latest",
201
+ * secrets: [{ source: "db-password", target: "db_password", mode: 0o400 }],
202
+ * configs: [{ source: "app-config", target: "/etc/app/config.yaml" }],
203
+ * });
204
+ * ```
205
+ *
206
+ * @resource
207
+ * @product Swarm
208
+ */
209
+ export const Service = Platform("Docker.Service", {
210
+ createRuntimeContext: createContainerRuntimeContext("Docker.Service"),
211
+ });
212
+ /** True when the props declare a bundled `main` program. */
213
+ const isBundledService = (props) => typeof props.main === "string";
214
+ /**
215
+ * A `docker service update` that swarm rolled back or paused instead of
216
+ * completing. `--detach=false` exits 0 on a rollback and 1 on a pause, so the
217
+ * provider reads the service's `UpdateStatus` after the update.
218
+ */
219
+ export class DockerServiceUpdateFailed extends Data.TaggedError("DockerServiceUpdateFailed") {
220
+ get message() {
221
+ return `Update of service "${this.service}" ended in ${this.state}: ${this.reason}`;
222
+ }
223
+ }
224
+ export const ServiceProvider = () => Provider.effect(Service, Effect.gen(function* () {
225
+ const docker = yield* Docker;
226
+ const stack = yield* Stack;
227
+ const serviceImage = yield* makeServiceImage;
228
+ // The bundled program's bootstrap reads these to rebuild the Stack at
229
+ // container runtime (same contract as the ECS bootstrap).
230
+ const alchemyEnv = {
231
+ ALCHEMY_STACK_NAME: stack.name,
232
+ ALCHEMY_STAGE: stack.stage,
233
+ ALCHEMY_PHASE: "runtime",
234
+ };
235
+ const inspect = (id, context) => docker.service.inspect(id, context).pipe(Effect.map((result) => normalizeServiceInspect(result)), Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
236
+ /**
237
+ * Resolve the image the service should run: build the bundled `main`
238
+ * program (content-addressed, skipped when already present in the
239
+ * target engine) or normalize the pre-built reference.
240
+ */
241
+ const resolveImage = Effect.fn(function* (id, props, name, context, session) {
242
+ if (isBundledService(props)) {
243
+ return yield* serviceImage.resolve({
244
+ id,
245
+ source: props,
246
+ name,
247
+ context,
248
+ isExternal: props.isExternal,
249
+ session,
250
+ });
251
+ }
252
+ return {
253
+ imageRef: normalizeImageRef(props.image),
254
+ codeHash: undefined,
255
+ };
256
+ });
257
+ return Service.Provider.of({
258
+ list: () => Effect.succeed([]),
259
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
260
+ const name = yield* servicePhysicalName(id, olds, instanceId);
261
+ const context = dockerEngineContextName(olds?.context);
262
+ const live = yield* inspect(name, context);
263
+ if (!live)
264
+ return undefined;
265
+ ensureReplicatedMode(live);
266
+ const attrs = toServiceAttributes(live, context, output?.code);
267
+ if (output)
268
+ return attrs;
269
+ const owned = yield* hasAlchemyTags(id, live.Spec.Labels ?? undefined);
270
+ return owned ? attrs : Unowned(attrs);
271
+ }),
272
+ diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) {
273
+ if (!isResolved(news))
274
+ return undefined;
275
+ const oldDesired = yield* normalizeDesired(id, olds, instanceId);
276
+ const newDesired = yield* normalizeDesired(id, news, instanceId);
277
+ if (!Equal.equals(oldDesired, newDesired)) {
278
+ return requiresReplacement(oldDesired, newDesired)
279
+ ? { action: "replace", deleteFirst: true }
280
+ : { action: "update" };
281
+ }
282
+ // Bundled program: the image identity is the content hash — a code
283
+ // or bootstrap change rolls the service onto the new image.
284
+ if (isBundledService(news)) {
285
+ const hash = yield* serviceImage.hash({
286
+ source: news,
287
+ isExternal: news.isExternal,
288
+ });
289
+ if (hash !== output?.code?.hash) {
290
+ return { action: "update" };
291
+ }
292
+ }
293
+ return { action: "noop" };
294
+ }),
295
+ reconcile: Effect.fn(function* ({ id, instanceId, news, olds, output, session }) {
296
+ const desired = yield* normalizeDesired(id, news, instanceId);
297
+ const resolved = yield* resolveImage(id, news, desired.name, desired.context, session);
298
+ const image = resolved.imageRef;
299
+ const code = resolved.codeHash ? { hash: resolved.codeHash } : undefined;
300
+ const environment = isBundledService(news)
301
+ ? { ...alchemyEnv, ...desired.environment }
302
+ : desired.environment;
303
+ if (output && olds) {
304
+ const oldDesired = yield* normalizeDesired(id, olds, instanceId);
305
+ if (deepEqual(oldDesired, desired) &&
306
+ (resolved.codeHash === undefined || resolved.codeHash === output.code?.hash)) {
307
+ const current = yield* inspect(output.id, desired.context);
308
+ if (current) {
309
+ ensureReplicatedMode(current);
310
+ return toServiceAttributes(current, desired.context, code);
311
+ }
312
+ }
313
+ }
314
+ if (output) {
315
+ const current = yield* inspect(output.id, desired.context);
316
+ if (current) {
317
+ ensureReplicatedMode(current);
318
+ const internalTags = yield* createInternalTags(id);
319
+ const allDesiredLabels = { ...internalTags, ...desired.labels };
320
+ const currentLabels = current.Spec.Labels ?? {};
321
+ const labelsToRemove = Object.keys(currentLabels).filter((k) => !(k in allDesiredLabels));
322
+ // `service update` only adds and removes list entries, so the
323
+ // removals are computed from the observed spec rather than olds.
324
+ const spec = current.Spec.TaskTemplate?.ContainerSpec;
325
+ const envToRemove = (spec?.Env ?? [])
326
+ .map((entry) => entry.split("=", 1)[0])
327
+ .filter((key) => !(key in environment));
328
+ const currentConstraints = current.Spec.TaskTemplate?.Placement?.Constraints ?? [];
329
+ const constraintsToRemove = currentConstraints.filter((c) => !desired.constraints.includes(c));
330
+ // `--constraint-add` appends without de-duplicating.
331
+ const constraintsToAdd = desired.constraints.filter((c) => !currentConstraints.includes(c));
332
+ const desiredTargets = new Set(desired.volumes.map((v) => v.containerPath));
333
+ const mountsToRemove = (spec?.Mounts ?? []).flatMap((m) => m.Target && !desiredTargets.has(m.Target) ? [m.Target] : []);
334
+ const desiredPorts = new Set(desired.ports.map(formatPublish));
335
+ const portsToRemove = (current.Spec.EndpointSpec?.Ports ?? []).flatMap((p) => p.TargetPort !== undefined &&
336
+ !desiredPorts.has(formatPublish({
337
+ external: p.PublishedPort,
338
+ internal: p.TargetPort,
339
+ protocol: p.Protocol,
340
+ mode: p.PublishMode,
341
+ }))
342
+ ? // `--publish-rm` matches target, protocol and publish mode.
343
+ [
344
+ `target=${p.TargetPort},protocol=${p.Protocol ?? "tcp"},mode=${p.PublishMode ?? "ingress"}`,
345
+ ]
346
+ : []);
347
+ // A rollback exits 0 but a paused rollout exits 1, so the
348
+ // outcome is read from `UpdateStatus` either way.
349
+ const updateError = yield* docker.service
350
+ .update({
351
+ context: desired.context,
352
+ id: output.id,
353
+ image,
354
+ replicas: desired.replicas,
355
+ "endpoint-mode": desired.endpointMode,
356
+ // Attaching a network the service is already on fails, so
357
+ // every attachment is detached and the desired set re-added.
358
+ "network-rm": (current.Spec.TaskTemplate?.Networks ?? []).flatMap((n) => n.Target ? [n.Target] : []),
359
+ "network-add": desired.networks.map(formatNetwork),
360
+ "constraint-rm": constraintsToRemove,
361
+ "constraint-add": constraintsToAdd,
362
+ "replicas-max-per-node": desired.maxReplicasPerNode,
363
+ "update-parallelism": desired.updateConfig?.parallelism,
364
+ "update-delay": desired.updateConfig?.delay,
365
+ "update-monitor": desired.updateConfig?.monitor,
366
+ "update-failure-action": desired.updateConfig?.failureAction,
367
+ "update-max-failure-ratio": desired.updateConfig?.maxFailureRatio,
368
+ "update-order": desired.updateConfig?.order,
369
+ "rollback-parallelism": desired.rollbackConfig?.parallelism,
370
+ "rollback-delay": desired.rollbackConfig?.delay,
371
+ "rollback-monitor": desired.rollbackConfig?.monitor,
372
+ "rollback-failure-action": desired.rollbackConfig?.failureAction,
373
+ "rollback-max-failure-ratio": desired.rollbackConfig?.maxFailureRatio,
374
+ "rollback-order": desired.rollbackConfig?.order,
375
+ "restart-condition": desired.restartPolicy?.condition,
376
+ "restart-delay": desired.restartPolicy?.delay,
377
+ "restart-max-attempts": desired.restartPolicy?.maxAttempts,
378
+ "restart-window": desired.restartPolicy?.window,
379
+ "health-cmd": desired.healthcheck
380
+ ? healthcheckCommand(desired.healthcheck.cmd)
381
+ : undefined,
382
+ "no-healthcheck": desired.healthcheck !== undefined &&
383
+ isHealthcheckDisabled(desired.healthcheck.cmd),
384
+ "health-interval": desired.healthcheck?.interval,
385
+ "health-timeout": desired.healthcheck?.timeout,
386
+ "health-retries": desired.healthcheck?.retries,
387
+ "health-start-period": desired.healthcheck?.startPeriod,
388
+ "stop-grace-period": desired.stopGracePeriod,
389
+ "mount-rm": mountsToRemove,
390
+ "mount-add": desired.volumes.map(formatMount),
391
+ // Re-adding a ref that is still attached conflicts on its
392
+ // target, so every attached ref is removed and the desired
393
+ // set re-added in the same update.
394
+ "secret-rm": (spec?.Secrets ?? []).flatMap((s) => s.SecretName ? [s.SecretName] : []),
395
+ "secret-add": desired.secrets.map(formatRef),
396
+ "config-rm": (spec?.Configs ?? []).flatMap((c) => c.ConfigName ? [c.ConfigName] : []),
397
+ "config-add": desired.configs.map(formatRef),
398
+ "read-only": desired.readOnlyRootFs || undefined,
399
+ "publish-rm": portsToRemove,
400
+ "publish-add": desired.ports.map(formatPublish),
401
+ "label-add": allDesiredLabels,
402
+ "label-rm": labelsToRemove,
403
+ "env-rm": envToRemove,
404
+ "env-add": environment,
405
+ args: desired.command.length > 0
406
+ ? desired.command.concat(desired.args).join(" ")
407
+ : undefined,
408
+ })
409
+ .pipe(Effect.as(undefined), Effect.catchTag("PlatformError", (error) => Effect.succeed(error)));
410
+ const updated = yield* inspectOrDie(inspect, output.id, desired.context);
411
+ // Swarm clears `UpdateStatus` whenever it accepts an update, so
412
+ // any status here belongs to this update.
413
+ const status = updated.UpdateStatus;
414
+ if (status?.State === "rollback_completed" ||
415
+ status?.State === "rollback_paused" ||
416
+ status?.State === "paused") {
417
+ return yield* new DockerServiceUpdateFailed({
418
+ service: updated.Spec.Name,
419
+ state: status.State,
420
+ reason: status.Message ?? "",
421
+ });
422
+ }
423
+ if (updateError) {
424
+ return yield* updateError;
425
+ }
426
+ // Best-effort: the service now runs the new content-addressed
427
+ // image, so drop the one built for the previous bundle.
428
+ if (output.code && output.code.hash !== resolved.codeHash) {
429
+ yield* docker.image
430
+ .remove(output.image, true, desired.context)
431
+ .pipe(Effect.catchCause(() => Effect.void));
432
+ }
433
+ return toServiceAttributes(updated, desired.context, code);
434
+ }
435
+ }
436
+ const existingByName = yield* inspect(desired.name, desired.context);
437
+ if (existingByName) {
438
+ ensureReplicatedMode(existingByName);
439
+ return toServiceAttributes(existingByName, desired.context, code);
440
+ }
441
+ const internalTags = yield* createInternalTags(id);
442
+ const allDesiredLabels = { ...internalTags, ...desired.labels };
443
+ yield* docker.service.create({
444
+ context: desired.context,
445
+ name: desired.name,
446
+ image,
447
+ replicas: desired.replicas,
448
+ "endpoint-mode": desired.endpointMode,
449
+ network: desired.networks.map(formatNetwork),
450
+ constraint: desired.constraints,
451
+ "replicas-max-per-node": desired.maxReplicasPerNode,
452
+ "placement-pref": desired.preferences,
453
+ "update-parallelism": desired.updateConfig?.parallelism,
454
+ "update-delay": desired.updateConfig?.delay,
455
+ "update-monitor": desired.updateConfig?.monitor,
456
+ "update-failure-action": desired.updateConfig?.failureAction,
457
+ "update-max-failure-ratio": desired.updateConfig?.maxFailureRatio,
458
+ "update-order": desired.updateConfig?.order,
459
+ "rollback-parallelism": desired.rollbackConfig?.parallelism,
460
+ "rollback-delay": desired.rollbackConfig?.delay,
461
+ "rollback-monitor": desired.rollbackConfig?.monitor,
462
+ "rollback-failure-action": desired.rollbackConfig?.failureAction,
463
+ "rollback-max-failure-ratio": desired.rollbackConfig?.maxFailureRatio,
464
+ "rollback-order": desired.rollbackConfig?.order,
465
+ "restart-condition": desired.restartPolicy?.condition,
466
+ "restart-delay": desired.restartPolicy?.delay,
467
+ "restart-max-attempts": desired.restartPolicy?.maxAttempts,
468
+ "restart-window": desired.restartPolicy?.window,
469
+ "health-cmd": desired.healthcheck
470
+ ? healthcheckCommand(desired.healthcheck.cmd)
471
+ : undefined,
472
+ "no-healthcheck": desired.healthcheck !== undefined && isHealthcheckDisabled(desired.healthcheck.cmd),
473
+ "health-interval": desired.healthcheck?.interval,
474
+ "health-timeout": desired.healthcheck?.timeout,
475
+ "health-retries": desired.healthcheck?.retries,
476
+ "health-start-period": desired.healthcheck?.startPeriod,
477
+ "stop-grace-period": desired.stopGracePeriod,
478
+ mount: desired.volumes.map(formatMount),
479
+ secret: desired.secrets.map(formatRef),
480
+ config: desired.configs.map(formatRef),
481
+ "read-only": desired.readOnlyRootFs || undefined,
482
+ publish: desired.ports.map(formatPublish),
483
+ label: allDesiredLabels,
484
+ env: environment,
485
+ command: desired.command,
486
+ args: desired.args,
487
+ });
488
+ return toServiceAttributes(yield* inspectOrDie(inspect, desired.name, desired.context), desired.context, code);
489
+ }),
490
+ delete: Effect.fn(({ output }) => docker
491
+ .run([
492
+ ...(output.context ? ["--context", output.context] : []),
493
+ "service",
494
+ "scale",
495
+ "--detach=false",
496
+ `${output.id}=0`,
497
+ ])
498
+ .pipe(Effect.flatMap(() => docker.service.remove(output.id, output.context)), Effect.flatMap(() => waitForServiceContainersReleased(docker, output.id, output.context)), Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
499
+ // Best-effort: drop the content-addressed image built for a
500
+ // bundled program so replaced/destroyed services don't strand
501
+ // local image tags.
502
+ Effect.andThen(output.code
503
+ ? docker.image
504
+ .remove(output.image, true, output.context)
505
+ .pipe(Effect.catchCause(() => Effect.void))
506
+ : Effect.void))),
507
+ });
508
+ }));
509
+ /** Swarm rejects service names longer than 63 characters. */
510
+ const servicePhysicalName = (id, props, instanceId) => dockerPhysicalName(id, props, instanceId, 63);
511
+ const normalizeDesired = (id, props, instanceId) => servicePhysicalName(id, props, instanceId).pipe(Effect.map((name) => ({
512
+ name,
513
+ context: dockerEngineContextName(props.context),
514
+ // The bundled form's image identity is its content hash (compared
515
+ // separately in diff) — the `image` prop is only the environment base
516
+ // there, and it participates in the hash.
517
+ image: isBundledService(props) ? undefined : normalizeImageRef(props.image),
518
+ command: props.command ?? [],
519
+ args: props.args ?? [],
520
+ environment: {
521
+ ...normalizeEnvironment(props.environment),
522
+ ...normalizeBoundEnv(props.env),
523
+ },
524
+ networks: normalizeNetworks(props.networks),
525
+ ports: normalizePorts(props.ports),
526
+ endpointMode: props.endpointMode ?? "vip",
527
+ replicas: normalizeReplicas(props.replicas),
528
+ constraints: [...(props.constraints ?? []), ...(props.placement?.constraints ?? [])],
529
+ maxReplicasPerNode: props.placement?.maxReplicasPerNode,
530
+ preferences: props.placement?.preferences ?? [],
531
+ updateConfig: props.updateConfig,
532
+ rollbackConfig: props.rollbackConfig,
533
+ restartPolicy: props.restartPolicy,
534
+ healthcheck: props.healthcheck,
535
+ stopGracePeriod: props.stopGracePeriod,
536
+ volumes: props.volumes ?? [],
537
+ secrets: props.secrets ?? [],
538
+ configs: props.configs ?? [],
539
+ readOnlyRootFs: props.readOnlyRootFs ?? false,
540
+ labels: props.labels ?? {},
541
+ })));
542
+ /**
543
+ * Fields `docker service update` changes in place. Swarm rolls the tasks
544
+ * onto the new spec following `updateConfig`, so `order: "start-first"` and
545
+ * `failureAction: "rollback"` apply.
546
+ */
547
+ const inPlaceFields = new Set([
548
+ "image",
549
+ "replicas",
550
+ "environment",
551
+ "labels",
552
+ "networks",
553
+ "constraints",
554
+ "maxReplicasPerNode",
555
+ "updateConfig",
556
+ "rollbackConfig",
557
+ "restartPolicy",
558
+ "healthcheck",
559
+ "stopGracePeriod",
560
+ "volumes",
561
+ "secrets",
562
+ "configs",
563
+ "ports",
564
+ ]);
565
+ /**
566
+ * Settings `service update` can change but not reset to the engine default:
567
+ * removing one, or a field inside one, replaces the service.
568
+ */
569
+ const resettableFields = new Set([
570
+ "maxReplicasPerNode",
571
+ "updateConfig",
572
+ "rollbackConfig",
573
+ "restartPolicy",
574
+ "healthcheck",
575
+ "stopGracePeriod",
576
+ ]);
577
+ const clearsSetting = (olds, news) => {
578
+ if (olds === undefined)
579
+ return false;
580
+ if (news === undefined)
581
+ return true;
582
+ if (typeof olds !== "object" || olds === null)
583
+ return false;
584
+ return Object.entries(olds).some(([key, value]) => value !== undefined && news[key] === undefined);
585
+ };
586
+ /**
587
+ * `--publish-rm` matches target, protocol and mode, and `--publish-add` skips
588
+ * a port already in the spec. Dropping one of several ports that share a
589
+ * target therefore drops the kept ones too, so that change replaces.
590
+ */
591
+ const dropsSharedTargetPort = (olds, news) => {
592
+ const kept = new Set(news.ports.map(formatPublish));
593
+ const key = (port) => `${port.internal}/${port.protocol}/${port.mode}`;
594
+ const removedKeys = new Set(olds.ports.filter((p) => !kept.has(formatPublish(p))).map(key));
595
+ return olds.ports.some((p) => kept.has(formatPublish(p)) && removedKeys.has(key(p)));
596
+ };
597
+ const requiresReplacement = (olds, news) => dropsSharedTargetPort(olds, news) ||
598
+ Object.keys(news).some((key) => !deepEqual(olds[key], news[key]) &&
599
+ (!inPlaceFields.has(key) ||
600
+ (resettableFields.has(key) && clearsSetting(olds[key], news[key]))));
601
+ const normalizeImageRef = (image) => typeof image === "string" ? image : image.imageRef;
602
+ const normalizeEnvironment = (environment) => Object.fromEntries(Object.entries(environment ?? {}).map(([key, value]) => [
603
+ key,
604
+ Redacted.isRedacted(value) ? Redacted.value(value) : value,
605
+ ]));
606
+ /**
607
+ * Platform-injected env (`props.env`) after Output resolution: values are
608
+ * usually marker-packed strings, but tolerate Redacted and structured values.
609
+ */
610
+ const normalizeBoundEnv = (env) => Object.fromEntries(Object.entries(env ?? {}).map(([key, value]) => [
611
+ key,
612
+ typeof value === "string"
613
+ ? value
614
+ : Redacted.isRedacted(value)
615
+ ? Redacted.value(value)
616
+ : JSON.stringify(value),
617
+ ]));
618
+ const normalizePorts = (ports) => (ports ?? []).map((port) => ({
619
+ external: port.external,
620
+ internal: port.internal,
621
+ protocol: port.protocol ?? "tcp",
622
+ mode: port.mode ?? "ingress",
623
+ }));
624
+ const normalizeReplicas = (replicas) => {
625
+ const value = replicas ?? 1;
626
+ if (!Number.isInteger(value) || value < 0) {
627
+ throw new Error("Service.replicas must be an integer >= 0");
628
+ }
629
+ return value;
630
+ };
631
+ const normalizeNetworks = (networks) => (networks ?? []).map((network) => typeof network === "string"
632
+ ? { name: network }
633
+ : { name: network.name, aliases: network.aliases });
634
+ const formatMount = (volume) => [
635
+ `type=${/^(\/|\.\/|\.\.\/|~)/.test(volume.hostPath) ? "bind" : "volume"}`,
636
+ `source=${volume.hostPath}`,
637
+ `target=${volume.containerPath}`,
638
+ ...(volume.readOnly ? ["readonly=true"] : []),
639
+ ].join(",");
640
+ const formatNetwork = (network) => [`name=${network.name}`, ...(network.aliases ?? []).map((a) => `alias=${a}`)].join(",");
641
+ const formatRef = (ref) => Object.entries(ref)
642
+ .filter(([, value]) => value !== undefined)
643
+ .map(([key, value]) => `${key}=${value}`)
644
+ .join(",");
645
+ const formatPublish = (port) => [
646
+ ...(port.external !== undefined ? [`published=${port.external}`] : []),
647
+ `target=${port.internal}`,
648
+ `protocol=${port.protocol ?? "tcp"}`,
649
+ `mode=${port.mode ?? "ingress"}`,
650
+ ].join(",");
651
+ const toServiceAttributes = (service, context, code) => ({
652
+ id: service.ID,
653
+ name: service.Spec.Name,
654
+ context,
655
+ image: service.Spec.TaskTemplate?.ContainerSpec?.Image ?? "",
656
+ replicas: service.Spec.Mode?.Replicated?.Replicas ?? 1,
657
+ networks: (service.Spec.TaskTemplate?.Networks ?? []).flatMap((n) => n.Target ? [n.Target] : []),
658
+ ports: (service.Spec.EndpointSpec?.Ports ?? []).flatMap((port) => {
659
+ if (port.PublishedPort === undefined || port.TargetPort === undefined) {
660
+ return [];
661
+ }
662
+ return [
663
+ {
664
+ external: port.PublishedPort,
665
+ internal: port.TargetPort,
666
+ protocol: port.Protocol ?? "tcp",
667
+ mode: port.PublishMode ?? "ingress",
668
+ },
669
+ ];
670
+ }),
671
+ labels: service.Spec.Labels ?? {},
672
+ endpointMode: service.Spec.EndpointSpec?.Mode ?? "vip",
673
+ createdAt: Date.parse(service.CreatedAt ?? "") || Date.now(),
674
+ updatedAt: Date.parse(service.UpdatedAt ?? "") || Date.now(),
675
+ code,
676
+ });
677
+ const ensureReplicatedMode = (service) => {
678
+ if (service.Spec.Mode?.Global !== undefined) {
679
+ throw new Error(`Docker.Service only supports replicated services. Service "${service.Spec.Name}" is global.`);
680
+ }
681
+ };
682
+ const parseInspectArray = (json) => {
683
+ const parsed = JSON.parse(json);
684
+ if (parsed.length === 0) {
685
+ throw new Error("docker inspect returned no objects");
686
+ }
687
+ return parsed[0];
688
+ };
689
+ const normalizeServiceInspect = (value) => {
690
+ if (typeof value === "object" && value !== null && "stdout" in value) {
691
+ const stdout = value.stdout;
692
+ if (typeof stdout === "string") {
693
+ return parseInspectArray(stdout);
694
+ }
695
+ }
696
+ return value;
697
+ };
698
+ const inspectOrDie = (inspect, nameOrId, context) => inspect(nameOrId, context).pipe(Effect.flatMap((value) => value ? Effect.succeed(value) : Effect.die(`Expected ${nameOrId} to exist after create`)));
699
+ const waitForServiceContainersReleased = (docker, serviceId, context) => {
700
+ const maxAttempts = 10;
701
+ const noContainers = Symbol.for("Docker.Service.NoContainers");
702
+ const poll = listServiceContainerIds(docker, serviceId, context).pipe(Effect.flatMap((containerIds) => containerIds.length === 0 ? Effect.fail(noContainers) : Effect.succeed(containerIds)));
703
+ return poll.pipe(Effect.repeat(Schedule.spaced("1 second").pipe(Schedule.upTo({ times: maxAttempts - 1 }))), Effect.catchIf((error) => error === noContainers, () => Effect.void), Effect.andThen(listServiceContainerIds(docker, serviceId, context)), Effect.flatMap((containerIds) => containerIds.length === 0
704
+ ? Effect.void
705
+ : // Best effort cleanup for lingering task containers that keep volumes attached.
706
+ Effect.forEach(containerIds, (containerId) => docker
707
+ .run([
708
+ ...(context ? ["--context", context] : []),
709
+ "container",
710
+ "rm",
711
+ "-f",
712
+ containerId,
713
+ ])
714
+ .pipe(Effect.catchCause(() => Effect.void)), { concurrency: "unbounded" }).pipe(Effect.as(undefined))));
715
+ };
716
+ const listServiceContainerIds = (docker, serviceId, context) => docker
717
+ .run([
718
+ ...(context ? ["--context", context] : []),
719
+ "ps",
720
+ "-a",
721
+ "--filter",
722
+ `label=com.docker.swarm.service.id=${serviceId}`,
723
+ "--format",
724
+ "{{.ID}}",
725
+ ])
726
+ .pipe(Effect.map((result) => result.stdout
727
+ .split("\n")
728
+ .map((line) => line.trim())
729
+ .filter((line) => line.length > 0)));
730
+ //# sourceMappingURL=Service.js.map