1import * as Effect from "effect/Effect";
2import * as Equal from "effect/Equal";
3import type { PlatformError } from "effect/PlatformError";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import type * as rolldown from "rolldown";
7import { Unowned } from "../AdoptPolicy.ts";
8import { deepEqual, isResolved } from "../Diff.ts";
9import { Platform, type Main, type PlatformProps } from "../Platform.ts";
10import * as Provider from "../Provider.ts";
11import type { Resource } from "../Resource.ts";
12import {
13 createContainerRuntimeContext,
14 type HostRuntimeContext,
15 type ServerHost,
16} from "../Server/Process.ts";
17import { Stack } from "../Stack.ts";
18import { createInternalTags, hasAlchemyTags } from "../Tags.ts";
19import {
20 Docker,
21 dockerEngineContextName,
22 dockerPhysicalName,
23} from "./Docker.ts";
24import type { Providers } from "./Providers.ts";
25import { makeServiceImage } from "./ServiceImage.ts";
26
27export interface ServicePropsBase extends PlatformProps {
28 /**
29 * Service name.
30 *
31 * @default Generated from stack, stage, logical id, and instance id.
32 */
33 name?: string;
34 /**
35 * The engine the service is deployed to: a Docker context name, a
36 * `Docker.Context` resource, or a `Docker.Swarm` — passing the swarm also
37 * orders the service after the swarm is initialized.
38 */
39 context?: Docker.EngineRef;
40 /** Entrypoint command passed after the image. */
41 command?: string[];
42 /** Additional args appended after `command`. */
43 args?: string[];
44 /** Environment variables. Use Redacted for secrets. */
45 environment?: Record<string, string | Redacted.Redacted<string>>;
46 /**
47 * Environment variables injected by the platform (bound `Config` values and
48 * future bindings). Merged after `environment`; usually not set directly.
49 */
50 env?: Record<string, any>;
51 /** Overlay networks attached to this service. */
52 networks?: Array<string | Service.NetworkAttachment>;
53 /** Published port mappings. */
54 ports?: Service.PortMapping[];
55 /**
56 * Service discovery endpoint mode.
57 *
58 * @default "vip"
59 */
60 endpointMode?: "vip" | "dnsrr";
61 /**
62 * Desired number of service replicas.
63 *
64 * @default 1
65 */
66 replicas?: number;
67 /** Placement controls for task scheduling. */
68 placement?: Service.Placement;
69 /** Deprecated alias for `placement.constraints`. */
70 constraints?: string[];
71 /** Rolling update strategy for service updates. */
72 updateConfig?: Service.RolloutConfig;
73 /** Rollback strategy applied when updates fail. */
74 rollbackConfig?: Service.RolloutConfig;
75 /** Task restart behavior. */
76 restartPolicy?: Service.RestartPolicy;
77 /** Container healthcheck for service tasks. */
78 healthcheck?: Service.Healthcheck;
79 /** Grace period before force-killing a task during shutdown, e.g. `"30s"`. */
80 stopGracePeriod?: string;
81 /** Volume or bind mounts. */
82 volumes?: Service.VolumeMapping[];
83 /** Swarm secrets mounted into task containers. */
84 secrets?: Service.SecretRef[];
85 /** Swarm configs mounted into task containers. */
86 configs?: Service.ConfigRef[];
87 /**
88 * Mount task root filesystem read-only.
89 *
90 * @default false
91 */
92 readOnlyRootFs?: boolean;
93 /**
94 * Service labels. Alchemy's internal ownership labels are added
95 * automatically.
96 */
97 labels?: Record<string, string>;
98}
99
100/**
101 * Run a pre-built image: a registry reference or a Docker image resource
102 * (`Docker.Image` / `Docker.RemoteImage`).
103 */
104export interface ImageServiceProps extends ServicePropsBase {
105 /** Docker image reference or Docker image resource. */
106 image: Service.Image;
107 main?: undefined;
108}
109
110/**
111 * Bundle an inline Effect program (`main`) into a generated image built
112 * against the service's Docker context. The optional `image` is the
113 * environment base (`FROM`) and must be able to run the bun runtime.
114 */
115export interface BundledServiceProps extends ServicePropsBase {
116 /**
117 * Module entrypoint for the bundled program. This should typically be
118 * `import.meta.url` (or `import.meta.filename`) from an inline Effect
119 * program.
120 */
121 main: string;
122 /**
123 * Environment image used as the generated Dockerfile's `FROM`. Must be
124 * able to run the bun runtime.
125 *
126 * @default "oven/bun:1"
127 */
128 image?: string;
129 /**
130 * Container port the bundled HTTP server listens on. Baked into the image
131 * as `ENV PORT` + `EXPOSE`; publish it to the swarm ingress with `ports`.
132 */
133 port?: number;
134 /**
135 * Named export to load from `main`.
136 *
137 * @default "default"
138 */
139 handler?: string;
140 /** Bundler configuration for the entrypoint. */
141 build?: {
142 input?: Partial<rolldown.InputOptions>;
143 output?: Partial<rolldown.OutputOptions>;
144 };
145}
146
147/**
148 * Service props — the image comes from exactly one of two sources, flat on
149 * the props: `image` (a pre-built reference) or `main` (a bundled Effect
150 * program).
151 */
152export type ServiceProps = ImageServiceProps | BundledServiceProps;
153
154export declare namespace Service {
155 type Image = string | { imageRef: string };
156
157 interface PortMapping {
158 /** Published port on the swarm node. Omit to let Swarm assign one dynamically. */
159 external?: number;
160 /** Container port receiving traffic. */
161 internal: number;
162 /**
163 * Protocol used for the mapping.
164 *
165 * @default "tcp"
166 */
167 protocol?: "tcp" | "udp";
168 /**
169 * Publish through the swarm routing mesh (`ingress`) or directly on each
170 * node (`host`).
171 *
172 * @default "ingress"
173 */
174 mode?: "ingress" | "host";
175 }
176
177 interface VolumeMapping {
178 /** Host path or named volume source. */
179 hostPath: string;
180 /** Container path. */
181 containerPath: string;
182 /**
183 * Mount read-only.
184 *
185 * @default false
186 */
187 readOnly?: boolean;
188 }
189
190 interface NetworkAttachment {
191 /** Network name. */
192 name: string;
193 /** Network aliases for the service's tasks. */
194 aliases?: string[];
195 }
196
197 interface Placement {
198 /** Placement constraints, e.g. `"node.role==worker"`. */
199 constraints?: string[];
200 /** Placement preferences, e.g. `"spread=node.labels.zone"`. */
201 preferences?: string[];
202 /** Maximum number of replicas per swarm node. */
203 maxReplicasPerNode?: number;
204 }
205
206 interface RolloutConfig {
207 /** Number of tasks updated simultaneously. */
208 parallelism?: number;
209 /** Delay between task updates, e.g. `"10s"`. */
210 delay?: string;
211 /** Duration to monitor each updated task for failure, e.g. `"30s"`. */
212 monitor?: string;
213 /** Action on update failure. */
214 failureAction?: "pause" | "continue" | "rollback";
215 /** Failure ratio tolerated during an update. */
216 maxFailureRatio?: number;
217 /** Operation order during updates. */
218 order?: "stop-first" | "start-first";
219 }
220
221 interface RestartPolicy {
222 /** Condition under which tasks restart. */
223 condition?: "none" | "on-failure" | "any";
224 /** Delay between restart attempts, e.g. `"5s"`. */
225 delay?: string;
226 /** Maximum restart attempts before giving up. */
227 maxAttempts?: number;
228 /** Window used to evaluate the restart policy, e.g. `"120s"`. */
229 window?: string;
230 }
231
232 interface Healthcheck {
233 /** Command to run for health checks. */
234 cmd: string[] | string;
235 /** Time between checks, e.g. `"30s"`. */
236 interval?: string;
237 /** Maximum time a check may run, e.g. `"5s"`. */
238 timeout?: string;
239 /** Consecutive failures before unhealthy. */
240 retries?: number;
241 /** Startup grace period, e.g. `"30s"`. */
242 startPeriod?: string;
243 }
244
245 interface SecretRef {
246 /** Swarm secret name. */
247 source: string;
248 /** Target file name in `/run/secrets/`. Defaults to `source`. */
249 target?: string;
250 /** File owner uid. */
251 uid?: string;
252 /** File owner gid. */
253 gid?: string;
254 /** File mode, e.g. `0o400`. */
255 mode?: number | string;
256 }
257
258 interface ConfigRef {
259 /** Swarm config name. */
260 source: string;
261 /** Target path in the container. Defaults to `/<source>`. */
262 target?: string;
263 /** File owner uid. */
264 uid?: string;
265 /** File owner gid. */
266 gid?: string;
267 /** File mode, e.g. `0o444`. */
268 mode?: number | string;
269 }
270}
271
272export interface Service extends Resource<
273 "Docker.Service",
274 ServiceProps,
275 {
276 /** Swarm service id. */
277 id: string;
278 /** Swarm service name. */
279 name: string;
280 /** Docker context the service is deployed to. */
281 context?: string;
282 /** Image reference the service runs. */
283 image: string;
284 /** Desired number of replicas. */
285 replicas: number;
286 /** Network ids the service's tasks attach to. */
287 networks: string[];
288 /** Published port mappings reported by Swarm. */
289 ports: Service.PortMapping[];
290 /** Service labels reported by Swarm. */
291 labels: Record<string, string>;
292 /** Service discovery endpoint mode. */
293 endpointMode: "vip" | "dnsrr";
294 /** Creation timestamp in milliseconds since epoch. */
295 createdAt: number;
296 /** Last update timestamp in milliseconds since epoch. */
297 updatedAt: number;
298 /** Content hash of the bundled program's image (`main` form only). */
299 code?: {
300 /** Content hash of the bundled program's image. */
301 hash: string;
302 };
303 },
304 never,
305 Providers
306> {}
307
308/** Services available to an effectful `Service` impl at init time. */
309export type ServiceServices = ServerHost;
310
311/**
312 * The impl shape for an effectful `Service`: a long-running server returning
313 * `{ fetch }` (plus optional RPC methods).
314 */
315export type ServiceShape = Main<ServiceServices>;
316
317export interface ServiceRuntimeContext extends HostRuntimeContext {
318 readonly Type: "Docker.Service";
319}
320
321/**
322 * A Docker Swarm service: N replicas of a container kept alive by the swarm,
323 * deployed through the active (or a named) Docker context.
324 *
325 * The target engine must be a swarm manager — `Service` wraps
326 * `docker service`, swarm mode's orchestration API. Declare the swarm with
327 * `Docker.Swarm` and pass it as `context` so the service deploys after the
328 * swarm exists; for a plain single container on a non-swarm daemon use
329 * `Docker.Container` instead.
330 *
331 * The service's image comes from one of two sources:
332 *
333 * - `image` — run a pre-built reference (a registry ref, or a `Docker.Image`
334 * / `Docker.RemoteImage` resource).
335 * - `main` — bundle an inline Effect program into a generated bun image,
336 * built directly against the service's Docker context. The impl returns
337 * `{ fetch }` and may register background loops via `ServerHost.run` —
338 * the same effectful platform shape as `AWS.ECS.Service`.
339 *
340 * The bundled image is content-addressed and only rebuilt when the program
341 * (or its generated Dockerfile) changes. It is built on the target engine's
342 * local store — single-node swarms run it as-is; multi-node swarms need the
343 * image on a registry every node can reach (build with `Docker.Image` +
344 * `registry` and pass the pushed ref as `image` instead).
345 *
346 * Only replicated services are supported. Configuration changes replace the
347 * service (delete-then-create); swarm tasks are stateless, so replacement is
348 * cheap and avoids partially-applied `service update` drift.
349 * ### Creating Services
350 * **Example:** Replicated Nginx
351 * ```typescript
352 * const swarm = yield* Docker.Swarm("swarm");
353 * const web = yield* Docker.Service("web", {
354 * context: swarm,
355 * image: "nginx:alpine",
356 * replicas: 3,
357 * ports: [{ external: 8080, internal: 80 }],
358 * });
359 * ```
360 *
361 * **Example:** Run a Built Image
362 * ```typescript
363 * const image = yield* Docker.Image("app-image", {
364 * build: { context: "./app" },
365 * });
366 * const app = yield* Docker.Service("app", {
367 * context: swarm,
368 * image,
369 * replicas: 2,
370 * });
371 * ```
372 *
373 * ### Effectful Services
374 * **Example:** Inline Effect Server
375 * ```typescript
376 * const swarm = yield* Docker.Swarm("swarm");
377 * const api = yield* Docker.Service(
378 * "Api",
379 * {
380 * context: swarm,
381 * main: import.meta.url,
382 * port: 3000,
383 * ports: [{ external: 8080, internal: 3000 }],
384 * replicas: 2,
385 * },
386 * Effect.gen(function* () {
387 * return {
388 * fetch: Effect.gen(function* () {
389 * return yield* HttpServerResponse.json({ ok: true });
390 * }),
391 * };
392 * }),
393 * );
394 * ```
395 *
396 * **Example:** Background Loops with ServerHost
397 * ```typescript
398 * // Class props may be an Effect, so the service can yield the swarm it
399 * // deploys into (declared once at module level).
400 * const Swarm = Docker.Swarm("swarm");
401 *
402 * export default class Worker extends Docker.Service<Worker>()(
403 * "Worker",
404 * Effect.gen(function* () {
405 * const swarm = yield* Swarm;
406 * return { context: swarm, main: import.meta.url, port: 3000 };
407 * }),
408 * Effect.gen(function* () {
409 * const host = yield* ServerHost;
410 * yield* host.run(
411 * pollQueue.pipe(Effect.repeat(Schedule.spaced("5 seconds")), Effect.asVoid),
412 * );
413 * return {
414 * fetch: Effect.succeed(HttpServerResponse.text("ok")),
415 * };
416 * }),
417 * ) {}
418 * ```
419 *
420 * ### Bundling & Tree-shaking
421 * `main` is bundled with rolldown at deploy time. Unused code is
422 * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked
423 * pure so unused parts prune more aggressively. Your app is not
424 * marked pure.
425 *
426 * **Example:** Mark additional packages as pure
427 * Only list packages with no top-level side effects.
428 * ```typescript
429 * {
430 * main: import.meta.url,
431 * build: {
432 * pure: { packages: ["my-lib", "@my-scope/*"] },
433 * },
434 * }
435 * ```
436 *
437 * **Example:** Turn it off
438 * ```typescript
439 * {
440 * main: import.meta.url,
441 * build: { pure: false },
442 * }
443 * ```
444 *
445 * ### Docker Contexts
446 * **Example:** Deploy to a Remote Swarm over SSH
447 * ```typescript
448 * const vps = yield* Docker.Context("vps", {
449 * docker: "host=ssh://deploy@example.com",
450 * });
451 * const swarm = yield* Docker.Swarm("swarm", {
452 * context: vps,
453 * advertiseAddr: "10.0.0.1",
454 * });
455 * const app = yield* Docker.Service("app", {
456 * context: swarm,
457 * image: "nginx:alpine",
458 * replicas: 3,
459 * });
460 * ```
461 *
462 * ### Networks & Volumes
463 * **Example:** Overlay Network with Aliases
464 * ```typescript
465 * const network = yield* Docker.Network("app-net", {
466 * context: swarm,
467 * driver: "overlay",
468 * });
469 * const db = yield* Docker.Service("db", {
470 * context: swarm,
471 * image: "postgres:18-alpine",
472 * networks: [{ name: network.name, aliases: ["postgres"] }],
473 * volumes: [{ hostPath: "pg-data", containerPath: "/var/lib/postgresql/data" }],
474 * });
475 * ```
476 *
477 * ### Rollouts & Placement
478 * **Example:** Rolling Update with Rollback
479 * ```typescript
480 * const app = yield* Docker.Service("app", {
481 * image: "ghcr.io/acme/app:latest",
482 * replicas: 4,
483 * updateConfig: {
484 * parallelism: 1,
485 * delay: "10s",
486 * failureAction: "rollback",
487 * order: "start-first",
488 * },
489 * placement: {
490 * constraints: ["node.role==worker"],
491 * maxReplicasPerNode: 2,
492 * },
493 * });
494 * ```
495 *
496 * ### Secrets & Configs
497 * **Example:** Mount Swarm Secrets
498 * ```typescript
499 * const app = yield* Docker.Service("app", {
500 * image: "ghcr.io/acme/app:latest",
501 * secrets: [{ source: "db-password", target: "db_password", mode: 0o400 }],
502 * configs: [{ source: "app-config", target: "/etc/app/config.yaml" }],
503 * });
504 * ```
505 *
506 * @resource
507 * @product Swarm
508 */
509export const Service: Platform<
510 Service,
511 ServiceServices,
512 ServiceShape,
513 ServiceRuntimeContext
514> = Platform("Docker.Service", {
515 createRuntimeContext: createContainerRuntimeContext("Docker.Service") as (
516 id: string,
517 ) => ServiceRuntimeContext,
518});
519
520/** True when the props declare a bundled `main` program. */
521const isBundledService = (props: ServiceProps): props is BundledServiceProps =>
522 typeof (props as BundledServiceProps).main === "string";
523
524type ServiceInspect = {
525 ID: string;
526 CreatedAt?: string;
527 UpdatedAt?: string;
528 Spec: {
529 Name: string;
530 Labels?: Record<string, string> | null;
531 TaskTemplate?: {
532 ContainerSpec?: {
533 Image?: string;
534 };
535 Networks?: Array<{ Target?: string }>;
536 Placement?: {
537 Constraints?: string[];
538 };
539 };
540 Mode?: {
541 Replicated?: {
542 Replicas?: number;
543 };
544 Global?: Record<string, never>;
545 };
546 EndpointSpec?: {
547 Mode?: "vip" | "dnsrr";
548 Ports?: Array<{
549 PublishedPort?: number;
550 TargetPort?: number;
551 Protocol?: "tcp" | "udp";
552 PublishMode?: "ingress" | "host";
553 }>;
554 };
555 };
556};
557
558export const ServiceProvider = () =>
559 Provider.effect(
560 Service,
561 Effect.gen(function* () {
562 const docker = yield* Docker;
563 const stack = yield* Stack;
564 const serviceImage = yield* makeServiceImage;
565
566 // The bundled program's bootstrap reads these to rebuild the Stack at
567 // container runtime (same contract as the ECS bootstrap).
568 const alchemyEnv = {
569 ALCHEMY_STACK_NAME: stack.name,
570 ALCHEMY_STAGE: stack.stage,
571 ALCHEMY_PHASE: "runtime",
572 };
573
574 const inspect = (id: string, context?: string) =>
575 docker.service.inspect(id, context).pipe(
576 Effect.map((result) => normalizeServiceInspect(result)),
577 Effect.catchReason(
578 "PlatformError",
579 "NotFound",
580 () => Effect.undefined,
581 ),
582 );
583
584 /**
585 * Resolve the image the service should run: build the bundled `main`
586 * program (content-addressed, skipped when already present in the
587 * target engine) or normalize the pre-built reference.
588 */
589 const resolveImage = Effect.fn(function* (
590 id: string,
591 props: ServiceProps,
592 name: string,
593 context: string | undefined,
594 session: { note: (message: string) => Effect.Effect<void> },
595 ) {
596 if (isBundledService(props)) {
597 return yield* serviceImage.resolve({
598 id,
599 source: props,
600 name,
601 context,
602 isExternal: props.isExternal,
603 session,
604 });
605 }
606 return {
607 imageRef: normalizeImageRef(props.image),
608 codeHash: undefined,
609 };
610 });
611
612 return Service.Provider.of({
613 list: () => Effect.succeed([]),
614 read: Effect.fn(function* ({ id, instanceId, olds, output }) {
615 const name = yield* servicePhysicalName(id, olds, instanceId);
616 const context = dockerEngineContextName(olds?.context);
617 const live = yield* inspect(name, context);
618 if (!live) return undefined;
619 ensureReplicatedMode(live);
620 const attrs = toServiceAttributes(live, context, output?.code);
621 if (output) return attrs;
622 const owned = yield* hasAlchemyTags(
623 id,
624 live.Spec.Labels ?? undefined,
625 );
626 return owned ? attrs : Unowned(attrs);
627 }),
628 diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) {
629 if (!isResolved(news)) return undefined;
630
631 const oldDesired = yield* normalizeDesired(id, olds, instanceId);
632 const newDesired = yield* normalizeDesired(id, news, instanceId);
633
634 if (!Equal.equals(oldDesired, newDesired)) {
635 return { action: "replace" as const, deleteFirst: true };
636 }
637
638 // Bundled program: the image identity is the content hash — a code
639 // or bootstrap change replaces the service with the new image.
640 if (isBundledService(news)) {
641 const hash = yield* serviceImage.hash({
642 source: news,
643 isExternal: news.isExternal,
644 });
645 if (hash !== output?.code?.hash) {
646 return { action: "replace" as const, deleteFirst: true };
647 }
648 }
649
650 return { action: "noop" as const };
651 }),
652 reconcile: Effect.fn(function* ({
653 id,
654 instanceId,
655 news,
656 olds,
657 output,
658 session,
659 }) {
660 const desired = yield* normalizeDesired(id, news, instanceId);
661 const resolved = yield* resolveImage(
662 id,
663 news,
664 desired.name,
665 desired.context,
666 session,
667 );
668 const image = resolved.imageRef;
669 const code = resolved.codeHash
670 ? { hash: resolved.codeHash }
671 : undefined;
672 const environment = isBundledService(news)
673 ? { ...alchemyEnv, ...desired.environment }
674 : desired.environment;
675
676 if (output && olds) {
677 const oldDesired = yield* normalizeDesired(id, olds, instanceId);
678 if (
679 deepEqual(oldDesired, desired) &&
680 (resolved.codeHash === undefined ||
681 resolved.codeHash === output.code?.hash)
682 ) {
683 const current = yield* inspect(output.id, desired.context);
684 if (current) {
685 ensureReplicatedMode(current);
686 return toServiceAttributes(current, desired.context, code);
687 }
688 }
689 }
690
691 if (output) {
692 const current = yield* inspect(output.id, desired.context);
693 if (current) {
694 ensureReplicatedMode(current);
695 const internalTags = yield* createInternalTags(id);
696 const allDesiredLabels = { ...internalTags, ...desired.labels };
697 const currentLabels = current.Spec.Labels ?? {};
698 const labelsToRemove = Object.keys(currentLabels).filter(
699 (k) => !(k in allDesiredLabels),
700 );
701 yield* docker.service.update({
702 context: desired.context,
703 id: output.id,
704 image,
705 replicas: desired.replicas,
706 "endpoint-mode": desired.endpointMode,
707 "constraint-add": desired.constraints,
708 "replicas-max-per-node": desired.maxReplicasPerNode,
709 "placement-pref": desired.preferences,
710 "update-parallelism": desired.updateConfig?.parallelism,
711 "update-delay": desired.updateConfig?.delay,
712 "update-monitor": desired.updateConfig?.monitor,
713 "update-failure-action": desired.updateConfig?.failureAction,
714 "update-max-failure-ratio":
715 desired.updateConfig?.maxFailureRatio,
716 "update-order": desired.updateConfig?.order,
717 "rollback-parallelism": desired.rollbackConfig?.parallelism,
718 "rollback-delay": desired.rollbackConfig?.delay,
719 "rollback-monitor": desired.rollbackConfig?.monitor,
720 "rollback-failure-action":
721 desired.rollbackConfig?.failureAction,
722 "rollback-max-failure-ratio":
723 desired.rollbackConfig?.maxFailureRatio,
724 "rollback-order": desired.rollbackConfig?.order,
725 "restart-condition": desired.restartPolicy?.condition,
726 "restart-delay": desired.restartPolicy?.delay,
727 "restart-max-attempts": desired.restartPolicy?.maxAttempts,
728 "restart-window": desired.restartPolicy?.window,
729 "health-cmd": desired.healthcheck
730 ? Array.isArray(desired.healthcheck.cmd)
731 ? desired.healthcheck.cmd.join(" ")
732 : desired.healthcheck.cmd
733 : undefined,
734 "health-interval": desired.healthcheck?.interval,
735 "health-timeout": desired.healthcheck?.timeout,
736 "health-retries": desired.healthcheck?.retries,
737 "health-start-period": desired.healthcheck?.startPeriod,
738 "stop-grace-period": desired.stopGracePeriod,
739 "mount-add": desired.volumes.map(formatMount),
740 "secret-add": desired.secrets.map(formatRef),
741 "config-add": desired.configs.map(formatRef),
742 "read-only": desired.readOnlyRootFs || undefined,
743 "publish-add": desired.ports.map(formatPublish),
744 "label-add": allDesiredLabels,
745 "label-rm": labelsToRemove,
746 "env-add": environment,
747 args:
748 desired.command.length > 0
749 ? desired.command.concat(desired.args).join(" ")
750 : undefined,
751 });
752
753 return toServiceAttributes(
754 yield* inspectOrDie(inspect, output.id, desired.context),
755 desired.context,
756 code,
757 );
758 }
759 }
760
761 const existingByName = yield* inspect(desired.name, desired.context);
762 if (existingByName) {
763 ensureReplicatedMode(existingByName);
764 return toServiceAttributes(existingByName, desired.context, code);
765 }
766
767 const internalTags = yield* createInternalTags(id);
768 const allDesiredLabels = { ...internalTags, ...desired.labels };
769 yield* docker.service.create({
770 context: desired.context,
771 name: desired.name,
772 image,
773 replicas: desired.replicas,
774 "endpoint-mode": desired.endpointMode,
775 network: desired.networks.map((n) =>
776 [
777 `name=${n.name}`,
778 ...(n.aliases ?? []).map((a) => `alias=${a}`),
779 ].join(","),
780 ),
781 constraint: desired.constraints,
782 "replicas-max-per-node": desired.maxReplicasPerNode,
783 "placement-pref": desired.preferences,
784 "update-parallelism": desired.updateConfig?.parallelism,
785 "update-delay": desired.updateConfig?.delay,
786 "update-monitor": desired.updateConfig?.monitor,
787 "update-failure-action": desired.updateConfig?.failureAction,
788 "update-max-failure-ratio": desired.updateConfig?.maxFailureRatio,
789 "update-order": desired.updateConfig?.order,
790 "rollback-parallelism": desired.rollbackConfig?.parallelism,
791 "rollback-delay": desired.rollbackConfig?.delay,
792 "rollback-monitor": desired.rollbackConfig?.monitor,
793 "rollback-failure-action": desired.rollbackConfig?.failureAction,
794 "rollback-max-failure-ratio":
795 desired.rollbackConfig?.maxFailureRatio,
796 "rollback-order": desired.rollbackConfig?.order,
797 "restart-condition": desired.restartPolicy?.condition,
798 "restart-delay": desired.restartPolicy?.delay,
799 "restart-max-attempts": desired.restartPolicy?.maxAttempts,
800 "restart-window": desired.restartPolicy?.window,
801 "health-cmd": desired.healthcheck
802 ? Array.isArray(desired.healthcheck.cmd)
803 ? desired.healthcheck.cmd.join(" ")
804 : desired.healthcheck.cmd
805 : undefined,
806 "health-interval": desired.healthcheck?.interval,
807 "health-timeout": desired.healthcheck?.timeout,
808 "health-retries": desired.healthcheck?.retries,
809 "health-start-period": desired.healthcheck?.startPeriod,
810 "stop-grace-period": desired.stopGracePeriod,
811 mount: desired.volumes.map(formatMount),
812 secret: desired.secrets.map(formatRef),
813 config: desired.configs.map(formatRef),
814 "read-only": desired.readOnlyRootFs || undefined,
815 publish: desired.ports.map(formatPublish),
816 label: allDesiredLabels,
817 env: environment,
818 command: desired.command,
819 args: desired.args,
820 });
821 return toServiceAttributes(
822 yield* inspectOrDie(inspect, desired.name, desired.context),
823 desired.context,
824 code,
825 );
826 }),
827 delete: Effect.fn(({ output }) =>
828 docker
829 .run([
830 ...(output.context ? ["--context", output.context] : []),
831 "service",
832 "scale",
833 "--detach=false",
834 `${output.id}=0`,
835 ])
836 .pipe(
837 Effect.flatMap(() =>
838 docker.service.remove(output.id, output.context),
839 ),
840 Effect.flatMap(() =>
841 waitForServiceContainersReleased(
842 docker,
843 output.id,
844 output.context,
845 ),
846 ),
847 Effect.catchReason(
848 "PlatformError",
849 "NotFound",
850 () => Effect.void,
851 ),
852 // Best-effort: drop the content-addressed image built for a
853 // bundled program so replaced/destroyed services don't strand
854 // local image tags.
855 Effect.andThen(
856 output.code
857 ? docker.image
858 .remove(output.image, true, output.context)
859 .pipe(Effect.catchCause(() => Effect.void))
860 : Effect.void,
861 ),
862 ),
863 ),
864 });
865 }),
866 );
867
868/** Swarm rejects service names longer than 63 characters. */
869const servicePhysicalName = (
870 id: string,
871 props: { name?: string } | undefined,
872 instanceId: string,
873) => dockerPhysicalName(id, props, instanceId, 63);
874
875const normalizeDesired = (
876 id: string,
877 props: ServiceProps,
878 instanceId: string,
879) =>
880 servicePhysicalName(id, props, instanceId).pipe(
881 Effect.map((name) => ({
882 name,
883 context: dockerEngineContextName(props.context),
884 // The bundled form's image identity is its content hash (compared
885 // separately in diff) — the `image` prop is only the environment base
886 // there, and it participates in the hash.
887 image: isBundledService(props)
888 ? undefined
889 : normalizeImageRef(props.image),
890 command: props.command ?? [],
891 args: props.args ?? [],
892 environment: {
893 ...normalizeEnvironment(props.environment),
894 ...normalizeBoundEnv(props.env),
895 },
896 networks: normalizeNetworks(props.networks),
897 ports: normalizePorts(props.ports),
898 endpointMode: props.endpointMode ?? "vip",
899 replicas: normalizeReplicas(props.replicas),
900 constraints: [
901 ...(props.constraints ?? []),
902 ...(props.placement?.constraints ?? []),
903 ],
904 maxReplicasPerNode: props.placement?.maxReplicasPerNode,
905 preferences: props.placement?.preferences ?? [],
906 updateConfig: props.updateConfig,
907 rollbackConfig: props.rollbackConfig,
908 restartPolicy: props.restartPolicy,
909 healthcheck: props.healthcheck,
910 stopGracePeriod: props.stopGracePeriod,
911 volumes: props.volumes ?? [],
912 secrets: props.secrets ?? [],
913 configs: props.configs ?? [],
914 readOnlyRootFs: props.readOnlyRootFs ?? false,
915 labels: props.labels ?? {},
916 })),
917 );
918
919const normalizeImageRef = (image: Service.Image): string =>
920 typeof image === "string" ? image : image.imageRef;
921
922const normalizeEnvironment = (
923 environment: Record<string, string | Redacted.Redacted<string>> | undefined,
924) =>
925 Object.fromEntries(
926 Object.entries(environment ?? {}).map(([key, value]) => [
927 key,
928 Redacted.isRedacted(value) ? Redacted.value(value) : value,
929 ]),
930 );
931
932/**
933 * Platform-injected env (`props.env`) after Output resolution: values are
934 * usually marker-packed strings, but tolerate Redacted and structured values.
935 */
936const normalizeBoundEnv = (
937 env: Record<string, any> | undefined,
938): Record<string, string> =>
939 Object.fromEntries(
940 Object.entries(env ?? {}).map(([key, value]) => [
941 key,
942 typeof value === "string"
943 ? value
944 : Redacted.isRedacted(value)
945 ? Redacted.value(value as Redacted.Redacted<string>)
946 : JSON.stringify(value),
947 ]),
948 );
949
950const normalizePorts = (
951 ports: Service.PortMapping[] | undefined,
952): Service.PortMapping[] =>
953 (ports ?? []).map((port) => ({
954 external: port.external,
955 internal: port.internal,
956 protocol: port.protocol ?? "tcp",
957 mode: port.mode ?? "ingress",
958 }));
959
960const normalizeReplicas = (replicas: number | undefined): number => {
961 const value = replicas ?? 1;
962 if (!Number.isInteger(value) || value < 0) {
963 throw new Error("Service.replicas must be an integer >= 0");
964 }
965 return value;
966};
967
968const normalizeNetworks = (
969 networks: Array<string | Service.NetworkAttachment> | undefined,
970): Service.NetworkAttachment[] =>
971 (networks ?? []).map((network) =>
972 typeof network === "string"
973 ? { name: network }
974 : { name: network.name, aliases: network.aliases },
975 );
976
977const formatMount = (volume: Service.VolumeMapping): string =>
978 [
979 `type=${/^(\/|\.\/|\.\.\/|~)/.test(volume.hostPath) ? "bind" : "volume"}`,
980 `source=${volume.hostPath}`,
981 `target=${volume.containerPath}`,
982 ...(volume.readOnly ? ["readonly=true"] : []),
983 ].join(",");
984
985const formatRef = (ref: Service.SecretRef | Service.ConfigRef): string =>
986 Object.entries(ref)
987 .filter(([, value]) => value !== undefined)
988 .map(([key, value]) => `${key}=${value}`)
989 .join(",");
990
991const formatPublish = (port: Service.PortMapping): string =>
992 [
993 ...(port.external !== undefined ? [`published=${port.external}`] : []),
994 `target=${port.internal}`,
995 `protocol=${port.protocol ?? "tcp"}`,
996 `mode=${port.mode ?? "ingress"}`,
997 ].join(",");
998
999const toServiceAttributes = (
1000 service: ServiceInspect,
1001 context?: string,
1002 code?: { hash: string },
1003): Service["Attributes"] => {
1004 return {
1005 id: service.ID,
1006 name: service.Spec.Name,
1007 context,
1008 image: service.Spec.TaskTemplate?.ContainerSpec?.Image ?? "",
1009 replicas: service.Spec.Mode?.Replicated?.Replicas ?? 1,
1010 networks: (service.Spec.TaskTemplate?.Networks ?? []).flatMap((n) =>
1011 n.Target ? [n.Target] : [],
1012 ),
1013 ports: (service.Spec.EndpointSpec?.Ports ?? []).flatMap((port) => {
1014 if (port.PublishedPort === undefined || port.TargetPort === undefined) {
1015 return [];
1016 }
1017 return [
1018 {
1019 external: port.PublishedPort,
1020 internal: port.TargetPort,
1021 protocol: port.Protocol ?? "tcp",
1022 mode: port.PublishMode ?? "ingress",
1023 },
1024 ];
1025 }),
1026 labels: service.Spec.Labels ?? {},
1027 endpointMode: service.Spec.EndpointSpec?.Mode ?? "vip",
1028 createdAt: Date.parse(service.CreatedAt ?? "") || Date.now(),
1029 updatedAt: Date.parse(service.UpdatedAt ?? "") || Date.now(),
1030 code,
1031 };
1032};
1033
1034const ensureReplicatedMode = (service: ServiceInspect): void => {
1035 if (service.Spec.Mode?.Global !== undefined) {
1036 throw new Error(
1037 `Docker.Service only supports replicated services. Service "${service.Spec.Name}" is global.`,
1038 );
1039 }
1040};
1041
1042const parseInspectArray = <T>(json: string): T => {
1043 const parsed = JSON.parse(json) as T[];
1044 if (parsed.length === 0) {
1045 throw new Error("docker inspect returned no objects");
1046 }
1047 return parsed[0]!;
1048};
1049
1050const normalizeServiceInspect = (value: unknown): ServiceInspect => {
1051 if (typeof value === "object" && value !== null && "stdout" in value) {
1052 const stdout = (value as { stdout?: unknown }).stdout;
1053 if (typeof stdout === "string") {
1054 return parseInspectArray<ServiceInspect>(stdout);
1055 }
1056 }
1057 return value as ServiceInspect;
1058};
1059
1060const inspectOrDie = <T>(
1061 inspect: (
1062 nameOrId: string,
1063 context?: string,
1064 ) => Effect.Effect<T | undefined, any>,
1065 nameOrId: string,
1066 context?: string,
1067) =>
1068 inspect(nameOrId, context).pipe(
1069 Effect.flatMap((value) =>
1070 value
1071 ? Effect.succeed(value)
1072 : Effect.die(`Expected ${nameOrId} to exist after create`),
1073 ),
1074 );
1075
1076const waitForServiceContainersReleased = (
1077 docker: Docker["Service"],
1078 serviceId: string,
1079 context?: string,
1080): Effect.Effect<void, PlatformError> => {
1081 const maxAttempts = 10;
1082 const noContainers = Symbol.for("Docker.Service.NoContainers");
1083
1084 const poll = listServiceContainerIds(docker, serviceId, context).pipe(
1085 Effect.flatMap((containerIds) =>
1086 containerIds.length === 0
1087 ? Effect.fail(noContainers)
1088 : Effect.succeed(containerIds),
1089 ),
1090 );
1091
1092 return poll.pipe(
1093 Effect.repeat(
1094 Schedule.spaced("1 second").pipe(
1095 Schedule.upTo({ times: maxAttempts - 1 }),
1096 ),
1097 ),
1098 Effect.catchIf(
1099 (error): error is typeof noContainers => error === noContainers,
1100 () => Effect.void,
1101 ),
1102 Effect.andThen(listServiceContainerIds(docker, serviceId, context)),
1103 Effect.flatMap((containerIds) =>
1104 containerIds.length === 0
1105 ? Effect.void
1106 : // Best effort cleanup for lingering task containers that keep volumes attached.
1107 Effect.forEach(
1108 containerIds,
1109 (containerId) =>
1110 docker
1111 .run([
1112 ...(context ? ["--context", context] : []),
1113 "container",
1114 "rm",
1115 "-f",
1116 containerId,
1117 ])
1118 .pipe(Effect.catchCause(() => Effect.void)),
1119 { concurrency: "unbounded" },
1120 ).pipe(Effect.as(undefined)),
1121 ),
1122 );
1123};
1124
1125const listServiceContainerIds = (
1126 docker: Docker["Service"],
1127 serviceId: string,
1128 context?: string,
1129): Effect.Effect<string[], PlatformError> =>
1130 docker
1131 .run([
1132 ...(context ? ["--context", context] : []),
1133 "ps",
1134 "-a",
1135 "--filter",
1136 `label=com.docker.swarm.service.id=${serviceId}`,
1137 "--format",
1138 "{{.ID}}",
1139 ])
1140 .pipe(
1141 Effect.map((result) =>
1142 result.stdout
1143 .split("\n")
1144 .map((line) => line.trim())
1145 .filter((line) => line.length > 0),
1146 ),
1147 );