1import * as Effect from "effect/Effect";
2import * as Equal from "effect/Equal";
3import { Unowned } from "../AdoptPolicy.ts";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import { Resource } from "../Resource.ts";
7import {
8 createInternalTags,
9 hasAlchemyTags,
10 stripInternalTags,
11} from "../Tags.ts";
12import {
13 Docker,
14 dockerEngineContextName,
15 dockerPhysicalName,
16} from "./Docker.ts";
17import type { Providers } from "./Providers.ts";
18
19export interface NetworkProps {
20 /**
21 * Docker network name.
22 *
23 * @default Generated from stack, stage, logical id, and instance id.
24 */
25 name?: string;
26 /** Network driver. @default "bridge" */
27 driver?: "bridge" | "host" | "none" | "overlay" | "macvlan" | (string & {});
28 /** Enable IPv6 on the network. @default false */
29 enableIPv6?: boolean;
30 /** Network labels. */
31 labels?: Record<string, string>;
32 /**
33 * The engine the network is created on: a Docker context name, a
34 * `Docker.Context` resource, or a `Docker.Swarm` — overlay networks
35 * require a swarm manager, and passing the swarm orders the network after
36 * the swarm is initialized.
37 */
38 context?: Docker.EngineRef;
39}
40
41export interface Network extends Resource<
42 "Docker.Network",
43 NetworkProps,
44 {
45 /** Docker network ID. */
46 id: string;
47 /** Docker network name. */
48 name: string;
49 /** Network driver. */
50 driver: string;
51 /** Whether IPv6 is enabled. */
52 enableIPv6: boolean;
53 /** Labels reported by Docker. */
54 labels: Record<string, string>;
55 /** Creation timestamp in milliseconds since epoch. */
56 createdAt: number;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * A Docker network managed through the active Docker context.
64 *
65 * Existing same-name networks are treated as foreign unless the engine is
66 * explicitly allowed to adopt them with `--adopt` or `adopt(true)`.
67 *
68 *
69 * ### Creating Networks
70 * **Example:** Basic bridge network
71 * ```typescript
72 * const network = yield* Docker.Network("app-network", {
73 * name: "app-network",
74 * });
75 * ```
76 *
77 * ### Adoption
78 * **Example:** Adopt a pre-existing network
79 * ```typescript
80 * const network = yield* Docker.Network("app-network", {
81 * name: "shared-app-network",
82 * }).pipe(adopt(true));
83 * ```
84 *
85 * @resource
86 * @product Network
87 */
88export const Network = Resource<Network>("Docker.Network");
89
90export const NetworkProvider = () =>
91 Provider.effect(
92 Network,
93 Effect.gen(function* () {
94 const docker = yield* Docker;
95
96 return Network.Provider.of({
97 list: () => Effect.succeed([]),
98 read: Effect.fn(function* ({ id, instanceId, olds, output }) {
99 const context = dockerEngineContextName(olds?.context);
100 const name = yield* dockerPhysicalName(id, olds, instanceId);
101 const info = yield* docker.network
102 .inspect(name, context)
103 .pipe(
104 Effect.catchReason(
105 "PlatformError",
106 "NotFound",
107 () => Effect.undefined,
108 ),
109 );
110 if (!info) return undefined;
111 const attrs = toNetworkAttributes(info);
112 if (output) return attrs;
113 // Without prior state, only adopt a network that carries our branding;
114 // anything else is foreign and gated behind `--adopt`.
115 const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined);
116 return owned ? attrs : Unowned(attrs);
117 }),
118 diff: Effect.fn(function* ({ id, output, instanceId, news, olds }) {
119 if (!isResolved(news) || !output) return undefined;
120 if (
121 dockerEngineContextName(olds?.context) !==
122 dockerEngineContextName(news?.context)
123 ) {
124 return { action: "replace", deleteFirst: true };
125 }
126 const args = yield* makeNetworkArgs(id, news, instanceId);
127 // Auto-generated names are engine-owned: the deployed name stays
128 // authoritative even if the generator would name this id differently
129 // today. Only an explicit user-provided name can force a replace.
130 const desiredName = news?.name ?? output.name;
131 if (
132 output.name !== desiredName ||
133 output.driver !== args.driver ||
134 output.enableIPv6 !== args.ipv6 ||
135 // Compare only user labels; internal `alchemy::*` branding lives on
136 // the observed network but must not drive replacement.
137 !Equal.equals(stripInternalTags(output.labels), args.label ?? {})
138 ) {
139 return { action: "replace", deleteFirst: true };
140 }
141 }),
142 reconcile: Effect.fn(function* ({ output, id, instanceId, news }) {
143 const context = dockerEngineContextName(news?.context);
144 if (output) {
145 const refreshed = yield* docker.network
146 .inspect(output.id, context)
147 .pipe(
148 Effect.map(toNetworkAttributes),
149 Effect.catchReason(
150 "PlatformError",
151 "NotFound",
152 () => Effect.undefined,
153 ),
154 );
155 if (refreshed) return refreshed;
156 }
157 const args = yield* makeNetworkArgs(id, news, instanceId);
158 const internalTags = yield* createInternalTags(id);
159 const { stdout: createdId } = yield* docker.network.create({
160 ...args,
161 label: { ...internalTags, ...args.label },
162 context,
163 });
164 return toNetworkAttributes(
165 yield* docker.network.inspect(createdId, context),
166 );
167 }),
168 delete: Effect.fn(({ olds, output }) =>
169 docker.network
170 .remove(output.id, dockerEngineContextName(olds?.context))
171 .pipe(
172 Effect.catchReason(
173 "PlatformError",
174 "NotFound",
175 () => Effect.void,
176 ),
177 ),
178 ),
179 });
180 }),
181 );
182
183const makeNetworkArgs = (
184 id: string,
185 props: NetworkProps | undefined,
186 instanceId: string,
187) =>
188 dockerPhysicalName(id, props, instanceId).pipe(
189 Effect.map(
190 (name): Parameters<Docker["Service"]["network"]["create"]>[0] => ({
191 name,
192 driver: props?.driver ?? "bridge",
193 ipv6: props?.enableIPv6 ?? false,
194 label: props?.labels ?? {},
195 }),
196 ),
197 );
198
199export const toNetworkAttributes = (
200 info: Docker.Network,
201): Network["Attributes"] => ({
202 id: info.Id,
203 name: info.Name,
204 driver: info.Driver,
205 enableIPv6: info.EnableIPv6,
206 labels: info.Labels ?? {},
207 createdAt: Date.parse(info.Created) || Date.now(),
208});