@alchemy.run/docker 2.0.0-beta.84 → 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/src/Service.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "alchemy/Runtime/Process";
11
11
  import { Stack } from "alchemy/Stack";
12
12
  import { createInternalTags, hasAlchemyTags } from "alchemy/Tags";
13
+ import * as Data from "effect/Data";
13
14
  import * as Effect from "effect/Effect";
14
15
  import * as Equal from "effect/Equal";
15
16
  import type { PlatformError } from "effect/PlatformError";
@@ -345,9 +346,14 @@ export interface ServiceRuntimeContext extends HostRuntimeContext {
345
346
  * image on a registry every node can reach (build with `Docker.Image` +
346
347
  * `registry` and pass the pushed ref as `image` instead).
347
348
  *
348
- * Only replicated services are supported. Configuration changes replace the
349
- * service (delete-then-create); swarm tasks are stateless, so replacement is
350
- * cheap and avoids partially-applied `service update` drift.
349
+ * Only replicated services are supported. Changes to the image, replicas,
350
+ * environment, labels, networks, ports, mounts, secrets, configs, constraints,
351
+ * healthcheck, and rollout or restart policies are applied in place with
352
+ * `docker service update`, so swarm rolls tasks over following `updateConfig`.
353
+ * An update that swarm rolls back or pauses fails the deploy with
354
+ * `DockerServiceUpdateFailed`. Changing the name, context, command, args,
355
+ * endpoint mode, placement preferences, or read-only root filesystem, or
356
+ * removing a policy setting, replaces the service (delete-then-create).
351
357
  * ### Creating Services
352
358
  * **Example:** Replicated Nginx
353
359
  * ```typescript
@@ -519,16 +525,39 @@ export const Service: Platform<Service, ServiceServices, ServiceShape, ServiceRu
519
525
  const isBundledService = (props: ServiceProps): props is BundledServiceProps =>
520
526
  typeof (props as BundledServiceProps).main === "string";
521
527
 
528
+ /**
529
+ * A `docker service update` that swarm rolled back or paused instead of
530
+ * completing. `--detach=false` exits 0 on a rollback and 1 on a pause, so the
531
+ * provider reads the service's `UpdateStatus` after the update.
532
+ */
533
+ export class DockerServiceUpdateFailed extends Data.TaggedError("DockerServiceUpdateFailed")<{
534
+ readonly service: string;
535
+ readonly state: string;
536
+ readonly reason: string;
537
+ }> {
538
+ override get message() {
539
+ return `Update of service "${this.service}" ended in ${this.state}: ${this.reason}`;
540
+ }
541
+ }
542
+
522
543
  type ServiceInspect = {
523
544
  ID: string;
524
545
  CreatedAt?: string;
525
546
  UpdatedAt?: string;
547
+ UpdateStatus?: {
548
+ State?: string;
549
+ Message?: string;
550
+ };
526
551
  Spec: {
527
552
  Name: string;
528
553
  Labels?: Record<string, string> | null;
529
554
  TaskTemplate?: {
530
555
  ContainerSpec?: {
531
556
  Image?: string;
557
+ Env?: string[] | null;
558
+ Mounts?: Array<{ Target?: string }> | null;
559
+ Secrets?: Array<{ SecretName?: string }> | null;
560
+ Configs?: Array<{ ConfigName?: string }> | null;
532
561
  };
533
562
  Networks?: Array<{ Target?: string }>;
534
563
  Placement?: {
@@ -623,18 +652,20 @@ export const ServiceProvider = () =>
623
652
  const newDesired = yield* normalizeDesired(id, news, instanceId);
624
653
 
625
654
  if (!Equal.equals(oldDesired, newDesired)) {
626
- return { action: "replace" as const, deleteFirst: true };
655
+ return requiresReplacement(oldDesired, newDesired)
656
+ ? { action: "replace" as const, deleteFirst: true }
657
+ : { action: "update" as const };
627
658
  }
628
659
 
629
660
  // Bundled program: the image identity is the content hash — a code
630
- // or bootstrap change replaces the service with the new image.
661
+ // or bootstrap change rolls the service onto the new image.
631
662
  if (isBundledService(news)) {
632
663
  const hash = yield* serviceImage.hash({
633
664
  source: news,
634
665
  isExternal: news.isExternal,
635
666
  });
636
667
  if (hash !== output?.code?.hash) {
637
- return { action: "replace" as const, deleteFirst: true };
668
+ return { action: "update" as const };
638
669
  }
639
670
  }
640
671
 
@@ -673,61 +704,144 @@ export const ServiceProvider = () =>
673
704
  const labelsToRemove = Object.keys(currentLabels).filter(
674
705
  (k) => !(k in allDesiredLabels),
675
706
  );
676
- yield* docker.service.update({
677
- context: desired.context,
678
- id: output.id,
679
- image,
680
- replicas: desired.replicas,
681
- "endpoint-mode": desired.endpointMode,
682
- "constraint-add": desired.constraints,
683
- "replicas-max-per-node": desired.maxReplicasPerNode,
684
- "placement-pref": desired.preferences,
685
- "update-parallelism": desired.updateConfig?.parallelism,
686
- "update-delay": desired.updateConfig?.delay,
687
- "update-monitor": desired.updateConfig?.monitor,
688
- "update-failure-action": desired.updateConfig?.failureAction,
689
- "update-max-failure-ratio": desired.updateConfig?.maxFailureRatio,
690
- "update-order": desired.updateConfig?.order,
691
- "rollback-parallelism": desired.rollbackConfig?.parallelism,
692
- "rollback-delay": desired.rollbackConfig?.delay,
693
- "rollback-monitor": desired.rollbackConfig?.monitor,
694
- "rollback-failure-action": desired.rollbackConfig?.failureAction,
695
- "rollback-max-failure-ratio": desired.rollbackConfig?.maxFailureRatio,
696
- "rollback-order": desired.rollbackConfig?.order,
697
- "restart-condition": desired.restartPolicy?.condition,
698
- "restart-delay": desired.restartPolicy?.delay,
699
- "restart-max-attempts": desired.restartPolicy?.maxAttempts,
700
- "restart-window": desired.restartPolicy?.window,
701
- "health-cmd": desired.healthcheck
702
- ? healthcheckCommand(desired.healthcheck.cmd)
703
- : undefined,
704
- "no-healthcheck":
705
- desired.healthcheck !== undefined &&
706
- isHealthcheckDisabled(desired.healthcheck.cmd),
707
- "health-interval": desired.healthcheck?.interval,
708
- "health-timeout": desired.healthcheck?.timeout,
709
- "health-retries": desired.healthcheck?.retries,
710
- "health-start-period": desired.healthcheck?.startPeriod,
711
- "stop-grace-period": desired.stopGracePeriod,
712
- "mount-add": desired.volumes.map(formatMount),
713
- "secret-add": desired.secrets.map(formatRef),
714
- "config-add": desired.configs.map(formatRef),
715
- "read-only": desired.readOnlyRootFs || undefined,
716
- "publish-add": desired.ports.map(formatPublish),
717
- "label-add": allDesiredLabels,
718
- "label-rm": labelsToRemove,
719
- "env-add": environment,
720
- args:
721
- desired.command.length > 0
722
- ? desired.command.concat(desired.args).join(" ")
707
+ // `service update` only adds and removes list entries, so the
708
+ // removals are computed from the observed spec rather than olds.
709
+ const spec = current.Spec.TaskTemplate?.ContainerSpec;
710
+ const envToRemove = (spec?.Env ?? [])
711
+ .map((entry) => entry.split("=", 1)[0]!)
712
+ .filter((key) => !(key in environment));
713
+ const currentConstraints = current.Spec.TaskTemplate?.Placement?.Constraints ?? [];
714
+ const constraintsToRemove = currentConstraints.filter(
715
+ (c) => !desired.constraints.includes(c),
716
+ );
717
+ // `--constraint-add` appends without de-duplicating.
718
+ const constraintsToAdd = desired.constraints.filter(
719
+ (c) => !currentConstraints.includes(c),
720
+ );
721
+ const desiredTargets = new Set(desired.volumes.map((v) => v.containerPath));
722
+ const mountsToRemove = (spec?.Mounts ?? []).flatMap((m) =>
723
+ m.Target && !desiredTargets.has(m.Target) ? [m.Target] : [],
724
+ );
725
+ const desiredPorts = new Set(desired.ports.map(formatPublish));
726
+ const portsToRemove = (current.Spec.EndpointSpec?.Ports ?? []).flatMap((p) =>
727
+ p.TargetPort !== undefined &&
728
+ !desiredPorts.has(
729
+ formatPublish({
730
+ external: p.PublishedPort,
731
+ internal: p.TargetPort,
732
+ protocol: p.Protocol,
733
+ mode: p.PublishMode,
734
+ }),
735
+ )
736
+ ? // `--publish-rm` matches target, protocol and publish mode.
737
+ [
738
+ `target=${p.TargetPort},protocol=${p.Protocol ?? "tcp"},mode=${p.PublishMode ?? "ingress"}`,
739
+ ]
740
+ : [],
741
+ );
742
+ // A rollback exits 0 but a paused rollout exits 1, so the
743
+ // outcome is read from `UpdateStatus` either way.
744
+ const updateError = yield* docker.service
745
+ .update({
746
+ context: desired.context,
747
+ id: output.id,
748
+ image,
749
+ replicas: desired.replicas,
750
+ "endpoint-mode": desired.endpointMode,
751
+ // Attaching a network the service is already on fails, so
752
+ // every attachment is detached and the desired set re-added.
753
+ "network-rm": (current.Spec.TaskTemplate?.Networks ?? []).flatMap((n) =>
754
+ n.Target ? [n.Target] : [],
755
+ ),
756
+ "network-add": desired.networks.map(formatNetwork),
757
+ "constraint-rm": constraintsToRemove,
758
+ "constraint-add": constraintsToAdd,
759
+ "replicas-max-per-node": desired.maxReplicasPerNode,
760
+ "update-parallelism": desired.updateConfig?.parallelism,
761
+ "update-delay": desired.updateConfig?.delay,
762
+ "update-monitor": desired.updateConfig?.monitor,
763
+ "update-failure-action": desired.updateConfig?.failureAction,
764
+ "update-max-failure-ratio": desired.updateConfig?.maxFailureRatio,
765
+ "update-order": desired.updateConfig?.order,
766
+ "rollback-parallelism": desired.rollbackConfig?.parallelism,
767
+ "rollback-delay": desired.rollbackConfig?.delay,
768
+ "rollback-monitor": desired.rollbackConfig?.monitor,
769
+ "rollback-failure-action": desired.rollbackConfig?.failureAction,
770
+ "rollback-max-failure-ratio": desired.rollbackConfig?.maxFailureRatio,
771
+ "rollback-order": desired.rollbackConfig?.order,
772
+ "restart-condition": desired.restartPolicy?.condition,
773
+ "restart-delay": desired.restartPolicy?.delay,
774
+ "restart-max-attempts": desired.restartPolicy?.maxAttempts,
775
+ "restart-window": desired.restartPolicy?.window,
776
+ "health-cmd": desired.healthcheck
777
+ ? healthcheckCommand(desired.healthcheck.cmd)
723
778
  : undefined,
724
- });
779
+ "no-healthcheck":
780
+ desired.healthcheck !== undefined &&
781
+ isHealthcheckDisabled(desired.healthcheck.cmd),
782
+ "health-interval": desired.healthcheck?.interval,
783
+ "health-timeout": desired.healthcheck?.timeout,
784
+ "health-retries": desired.healthcheck?.retries,
785
+ "health-start-period": desired.healthcheck?.startPeriod,
786
+ "stop-grace-period": desired.stopGracePeriod,
787
+ "mount-rm": mountsToRemove,
788
+ "mount-add": desired.volumes.map(formatMount),
789
+ // Re-adding a ref that is still attached conflicts on its
790
+ // target, so every attached ref is removed and the desired
791
+ // set re-added in the same update.
792
+ "secret-rm": (spec?.Secrets ?? []).flatMap((s) =>
793
+ s.SecretName ? [s.SecretName] : [],
794
+ ),
795
+ "secret-add": desired.secrets.map(formatRef),
796
+ "config-rm": (spec?.Configs ?? []).flatMap((c) =>
797
+ c.ConfigName ? [c.ConfigName] : [],
798
+ ),
799
+ "config-add": desired.configs.map(formatRef),
800
+ "read-only": desired.readOnlyRootFs || undefined,
801
+ "publish-rm": portsToRemove,
802
+ "publish-add": desired.ports.map(formatPublish),
803
+ "label-add": allDesiredLabels,
804
+ "label-rm": labelsToRemove,
805
+ "env-rm": envToRemove,
806
+ "env-add": environment,
807
+ args:
808
+ desired.command.length > 0
809
+ ? desired.command.concat(desired.args).join(" ")
810
+ : undefined,
811
+ })
812
+ .pipe(
813
+ Effect.as(undefined),
814
+ Effect.catchTag("PlatformError", (error) => Effect.succeed(error)),
815
+ );
725
816
 
726
- return toServiceAttributes(
727
- yield* inspectOrDie(inspect, output.id, desired.context),
728
- desired.context,
729
- code,
730
- );
817
+ const updated = yield* inspectOrDie(inspect, output.id, desired.context);
818
+ // Swarm clears `UpdateStatus` whenever it accepts an update, so
819
+ // any status here belongs to this update.
820
+ const status = updated.UpdateStatus;
821
+ if (
822
+ status?.State === "rollback_completed" ||
823
+ status?.State === "rollback_paused" ||
824
+ status?.State === "paused"
825
+ ) {
826
+ return yield* new DockerServiceUpdateFailed({
827
+ service: updated.Spec.Name,
828
+ state: status.State,
829
+ reason: status.Message ?? "",
830
+ });
831
+ }
832
+ if (updateError) {
833
+ return yield* updateError;
834
+ }
835
+
836
+ // Best-effort: the service now runs the new content-addressed
837
+ // image, so drop the one built for the previous bundle.
838
+ if (output.code && output.code.hash !== resolved.codeHash) {
839
+ yield* docker.image
840
+ .remove(output.image, true, desired.context)
841
+ .pipe(Effect.catchCause(() => Effect.void));
842
+ }
843
+
844
+ return toServiceAttributes(updated, desired.context, code);
731
845
  }
732
846
  }
733
847
 
@@ -745,9 +859,7 @@ export const ServiceProvider = () =>
745
859
  image,
746
860
  replicas: desired.replicas,
747
861
  "endpoint-mode": desired.endpointMode,
748
- network: desired.networks.map((n) =>
749
- [`name=${n.name}`, ...(n.aliases ?? []).map((a) => `alias=${a}`)].join(","),
750
- ),
862
+ network: desired.networks.map(formatNetwork),
751
863
  constraint: desired.constraints,
752
864
  "replicas-max-per-node": desired.maxReplicasPerNode,
753
865
  "placement-pref": desired.preferences,
@@ -866,6 +978,75 @@ const normalizeDesired = (id: string, props: ServiceProps, instanceId: string) =
866
978
  })),
867
979
  );
868
980
 
981
+ type DesiredService = Effect.Success<ReturnType<typeof normalizeDesired>>;
982
+
983
+ /**
984
+ * Fields `docker service update` changes in place. Swarm rolls the tasks
985
+ * onto the new spec following `updateConfig`, so `order: "start-first"` and
986
+ * `failureAction: "rollback"` apply.
987
+ */
988
+ const inPlaceFields = new Set<keyof DesiredService>([
989
+ "image",
990
+ "replicas",
991
+ "environment",
992
+ "labels",
993
+ "networks",
994
+ "constraints",
995
+ "maxReplicasPerNode",
996
+ "updateConfig",
997
+ "rollbackConfig",
998
+ "restartPolicy",
999
+ "healthcheck",
1000
+ "stopGracePeriod",
1001
+ "volumes",
1002
+ "secrets",
1003
+ "configs",
1004
+ "ports",
1005
+ ]);
1006
+
1007
+ /**
1008
+ * Settings `service update` can change but not reset to the engine default:
1009
+ * removing one, or a field inside one, replaces the service.
1010
+ */
1011
+ const resettableFields = new Set<keyof DesiredService>([
1012
+ "maxReplicasPerNode",
1013
+ "updateConfig",
1014
+ "rollbackConfig",
1015
+ "restartPolicy",
1016
+ "healthcheck",
1017
+ "stopGracePeriod",
1018
+ ]);
1019
+
1020
+ const clearsSetting = (olds: unknown, news: unknown): boolean => {
1021
+ if (olds === undefined) return false;
1022
+ if (news === undefined) return true;
1023
+ if (typeof olds !== "object" || olds === null) return false;
1024
+ return Object.entries(olds).some(
1025
+ ([key, value]) => value !== undefined && (news as Record<string, unknown>)[key] === undefined,
1026
+ );
1027
+ };
1028
+
1029
+ /**
1030
+ * `--publish-rm` matches target, protocol and mode, and `--publish-add` skips
1031
+ * a port already in the spec. Dropping one of several ports that share a
1032
+ * target therefore drops the kept ones too, so that change replaces.
1033
+ */
1034
+ const dropsSharedTargetPort = (olds: DesiredService, news: DesiredService): boolean => {
1035
+ const kept = new Set(news.ports.map(formatPublish));
1036
+ const key = (port: Service.PortMapping) => `${port.internal}/${port.protocol}/${port.mode}`;
1037
+ const removedKeys = new Set(olds.ports.filter((p) => !kept.has(formatPublish(p))).map(key));
1038
+ return olds.ports.some((p) => kept.has(formatPublish(p)) && removedKeys.has(key(p)));
1039
+ };
1040
+
1041
+ const requiresReplacement = (olds: DesiredService, news: DesiredService): boolean =>
1042
+ dropsSharedTargetPort(olds, news) ||
1043
+ (Object.keys(news) as Array<keyof DesiredService>).some(
1044
+ (key) =>
1045
+ !deepEqual(olds[key], news[key]) &&
1046
+ (!inPlaceFields.has(key) ||
1047
+ (resettableFields.has(key) && clearsSetting(olds[key], news[key]))),
1048
+ );
1049
+
869
1050
  const normalizeImageRef = (image: Service.Image): string =>
870
1051
  typeof image === "string" ? image : image.imageRef;
871
1052
 
@@ -928,6 +1109,9 @@ const formatMount = (volume: Service.VolumeMapping): string =>
928
1109
  ...(volume.readOnly ? ["readonly=true"] : []),
929
1110
  ].join(",");
930
1111
 
1112
+ const formatNetwork = (network: Service.NetworkAttachment): string =>
1113
+ [`name=${network.name}`, ...(network.aliases ?? []).map((a) => `alias=${a}`)].join(",");
1114
+
931
1115
  const formatRef = (ref: Service.SecretRef | Service.ConfigRef): string =>
932
1116
  Object.entries(ref)
933
1117
  .filter(([, value]) => value !== undefined)