@alchemy.run/docker 2.0.0-beta.85 → 2.0.0-beta.86

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 (93) hide show
  1. package/README.md +16 -0
  2. package/lib/Bootstrap.d.ts.map +1 -1
  3. package/lib/Bootstrap.js +2 -1
  4. package/lib/Bootstrap.js.map +1 -1
  5. package/lib/BuildCache.d.ts +87 -0
  6. package/lib/BuildCache.d.ts.map +1 -0
  7. package/lib/BuildCache.js +39 -0
  8. package/lib/BuildCache.js.map +1 -0
  9. package/lib/BuildHash.d.ts +9 -1
  10. package/lib/BuildHash.d.ts.map +1 -1
  11. package/lib/BuildHash.js +51 -19
  12. package/lib/BuildHash.js.map +1 -1
  13. package/lib/BuildPlan.d.ts +202 -0
  14. package/lib/BuildPlan.d.ts.map +1 -0
  15. package/lib/BuildPlan.js +514 -0
  16. package/lib/BuildPlan.js.map +1 -0
  17. package/lib/Container.d.ts +24 -1
  18. package/lib/Container.d.ts.map +1 -1
  19. package/lib/Container.js +14 -0
  20. package/lib/Container.js.map +1 -1
  21. package/lib/Context.d.ts +20 -1
  22. package/lib/Context.d.ts.map +1 -1
  23. package/lib/Context.js +15 -0
  24. package/lib/Context.js.map +1 -1
  25. package/lib/Docker.d.ts +33 -0
  26. package/lib/Docker.d.ts.map +1 -1
  27. package/lib/Docker.js +23 -2
  28. package/lib/Docker.js.map +1 -1
  29. package/lib/Dockerfile.d.ts +0 -2
  30. package/lib/Dockerfile.d.ts.map +1 -1
  31. package/lib/Dockerfile.js.map +1 -1
  32. package/lib/DockerfileParser.d.ts +125 -0
  33. package/lib/DockerfileParser.d.ts.map +1 -0
  34. package/lib/DockerfileParser.js +1040 -0
  35. package/lib/DockerfileParser.js.map +1 -0
  36. package/lib/HealthcheckCommand.d.ts.map +1 -1
  37. package/lib/HealthcheckCommand.js.map +1 -1
  38. package/lib/Image.d.ts +40 -12
  39. package/lib/Image.d.ts.map +1 -1
  40. package/lib/Image.js +41 -9
  41. package/lib/Image.js.map +1 -1
  42. package/lib/ImageArchive.d.ts +41 -0
  43. package/lib/ImageArchive.d.ts.map +1 -0
  44. package/lib/ImageArchive.js +54 -0
  45. package/lib/ImageArchive.js.map +1 -0
  46. package/lib/ImageSource.d.ts +100 -11
  47. package/lib/ImageSource.d.ts.map +1 -1
  48. package/lib/ImageSource.js +219 -73
  49. package/lib/ImageSource.js.map +1 -1
  50. package/lib/Network.d.ts +175 -3
  51. package/lib/Network.d.ts.map +1 -1
  52. package/lib/Network.js +296 -8
  53. package/lib/Network.js.map +1 -1
  54. package/lib/Providers.d.ts.map +1 -1
  55. package/lib/Providers.js.map +1 -1
  56. package/lib/Registry.d.ts.map +1 -1
  57. package/lib/Registry.js.map +1 -1
  58. package/lib/RegistryError.d.ts.map +1 -1
  59. package/lib/RegistryError.js.map +1 -1
  60. package/lib/RemoteImage.d.ts.map +1 -1
  61. package/lib/RemoteImage.js.map +1 -1
  62. package/lib/Service.d.ts.map +1 -1
  63. package/lib/Service.js.map +1 -1
  64. package/lib/ServiceImage.d.ts.map +1 -1
  65. package/lib/ServiceImage.js.map +1 -1
  66. package/lib/ShellWord.d.ts +28 -0
  67. package/lib/ShellWord.d.ts.map +1 -0
  68. package/lib/ShellWord.js +301 -0
  69. package/lib/ShellWord.js.map +1 -0
  70. package/lib/Swarm.d.ts.map +1 -1
  71. package/lib/Swarm.js.map +1 -1
  72. package/lib/Volume.d.ts +24 -1
  73. package/lib/Volume.d.ts.map +1 -1
  74. package/lib/Volume.js +16 -0
  75. package/lib/Volume.js.map +1 -1
  76. package/lib/index.d.ts.map +1 -1
  77. package/lib/index.js.map +1 -1
  78. package/package.json +9 -9
  79. package/src/Bootstrap.ts +2 -0
  80. package/src/BuildCache.ts +111 -0
  81. package/src/BuildHash.ts +73 -24
  82. package/src/BuildPlan.ts +758 -0
  83. package/src/Container.ts +31 -1
  84. package/src/Context.ts +27 -1
  85. package/src/Docker.ts +56 -2
  86. package/src/Dockerfile.ts +0 -2
  87. package/src/DockerfileParser.ts +1152 -0
  88. package/src/Image.ts +45 -14
  89. package/src/ImageArchive.ts +77 -0
  90. package/src/ImageSource.ts +322 -103
  91. package/src/Network.ts +432 -10
  92. package/src/ShellWord.ts +326 -0
  93. package/src/Volume.ts +33 -1
package/src/Network.ts CHANGED
@@ -1,13 +1,42 @@
1
- import { Unowned } from "alchemy/AdoptPolicy";
1
+ import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
2
2
  import { isResolved } from "alchemy/Diff";
3
3
  import * as Provider from "alchemy/Provider";
4
4
  import { Resource } from "alchemy/Resource";
5
5
  import { createInternalTags, hasAlchemyTags, stripInternalTags } from "alchemy/Tags";
6
+ import * as Data from "effect/Data";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Equal from "effect/Equal";
9
+ import * as Net from "effect/net";
10
+ import * as Result from "effect/Result";
11
+ import * as Schema from "effect/Schema";
8
12
  import { Docker, dockerEngineContextName, dockerPhysicalName } from "./Docker.ts";
9
13
  import type { Providers } from "./Providers.ts";
10
14
 
15
+ /** Address allocation settings for one Docker network subnet. */
16
+ export interface NetworkIpamConfig {
17
+ /** IPv4 or IPv6 CIDR network prefix, without host bits. */
18
+ subnet: string;
19
+ /** Gateway address in the subnet's address family. Docker chooses one when omitted. */
20
+ gateway?: string;
21
+ /** CIDR sub-range from which Docker allocates container addresses. */
22
+ ipRange?: string;
23
+ /** Map of reservation names to IP addresses in the subnet's address family. */
24
+ auxAddresses?: Record<string, string>;
25
+ }
26
+
27
+ /** Docker network IP Address Management (IPAM) configuration. */
28
+ export interface NetworkIpam {
29
+ /** IPAM driver. @default "default" */
30
+ driver?: string;
31
+ /** IPAM driver-specific key/value options. */
32
+ options?: Record<string, string>;
33
+ /**
34
+ * Subnet configurations. Docker allocates a subnet automatically when omitted;
35
+ * drivers differ in how many explicit subnet entries they accept.
36
+ */
37
+ config?: NetworkIpamConfig[];
38
+ }
39
+
11
40
  export interface NetworkProps {
12
41
  /**
13
42
  * Docker network name.
@@ -17,9 +46,23 @@ export interface NetworkProps {
17
46
  name?: string;
18
47
  /** Network driver. @default "bridge" */
19
48
  driver?: "bridge" | "host" | "none" | "overlay" | "macvlan" | (string & {});
49
+ /**
50
+ * Restrict external connectivity through this network. Containers connected
51
+ * to other networks can still have an external route.
52
+ * @default false
53
+ */
54
+ internal?: boolean;
55
+ /** Allow standalone containers to attach to an overlay network. @default false */
56
+ attachable?: boolean;
57
+ /** Enable IPv4 address allocation. Explicit `false` requires IPv6. @default true */
58
+ enableIPv4?: boolean;
20
59
  /** Enable IPv6 on the network. @default false */
21
60
  enableIPv6?: boolean;
22
- /** Network labels. */
61
+ /** Network-driver-specific key/value options, as passed to Docker `--opt`. */
62
+ driverOpts?: Record<string, string>;
63
+ /** Explicit IPAM configuration. Docker allocates missing subnet/gateway values. */
64
+ ipam?: NetworkIpam;
65
+ /** User labels. The `alchemy::*` prefix is reserved for resource ownership. */
23
66
  labels?: Record<string, string>;
24
67
  /**
25
68
  * The engine the network is created on: a Docker context name, a
@@ -40,24 +83,52 @@ export interface Network extends Resource<
40
83
  name: string;
41
84
  /** Network driver. */
42
85
  driver: string;
86
+ /** Whether the network is isolated from external connectivity. */
87
+ internal: boolean;
88
+ /** Whether manual container attachment is allowed. */
89
+ attachable: boolean;
90
+ /** Whether IPv4 address allocation is enabled. */
91
+ enableIPv4: boolean;
43
92
  /** Whether IPv6 is enabled. */
44
93
  enableIPv6: boolean;
94
+ /** Driver options reported by Docker. */
95
+ driverOpts: Record<string, string>;
96
+ /** IPAM configuration reported by Docker (including automatically allocated values). */
97
+ ipam: {
98
+ driver: string;
99
+ options: Record<string, string>;
100
+ config: NetworkIpamConfig[];
101
+ };
45
102
  /** Labels reported by Docker. */
46
103
  labels: Record<string, string>;
47
104
  /** Creation timestamp in milliseconds since epoch. */
48
105
  createdAt: number;
49
106
  },
50
107
  never,
51
- Providers
108
+ Providers,
109
+ NetworkLookup
52
110
  > {}
53
111
 
112
+ /**
113
+ * Props for {@link Network.lookup}: find a network this stack
114
+ * doesn't manage by its name.
115
+ */
116
+ export interface NetworkLookup {
117
+ /** Network name. */
118
+ name: string;
119
+ /**
120
+ * Docker context the engine is reached through.
121
+ * @default the current context
122
+ */
123
+ context?: string;
124
+ }
125
+
54
126
  /**
55
127
  * A Docker network managed through the active Docker context.
56
128
  *
57
129
  * Existing same-name networks are treated as foreign unless the engine is
58
130
  * explicitly allowed to adopt them with `--adopt` or `adopt(true)`.
59
131
  *
60
- *
61
132
  * ### Creating Networks
62
133
  * **Example:** Basic bridge network
63
134
  * ```typescript
@@ -66,6 +137,76 @@ export interface Network extends Resource<
66
137
  * });
67
138
  * ```
68
139
  *
140
+ * ### Isolation and address allocation
141
+ * **Example:** Internal bridge network with an explicit subnet and driver option
142
+ * ```typescript
143
+ * const network = yield* Docker.Network("private-network", {
144
+ * internal: true,
145
+ * attachable: true,
146
+ * enableIPv4: true,
147
+ * driverOpts: { "com.docker.network.driver.mtu": "1420" },
148
+ * ipam: {
149
+ * config: [{
150
+ * subnet: "10.50.0.0/24",
151
+ * gateway: "10.50.0.1",
152
+ * ipRange: "10.50.0.128/25",
153
+ * auxAddresses: { reserved: "10.50.0.5" },
154
+ * }],
155
+ * },
156
+ * });
157
+ * ```
158
+ *
159
+ * An internal network restricts external connectivity through that network;
160
+ * additional container network attachments can still provide external routes.
161
+ * If IPAM is omitted, Docker allocates a subnet and gateway. Alchemy does not
162
+ * treat those automatically assigned values as configuration drift.
163
+ * IPAM subnet and range values must be valid CIDR network prefixes (for
164
+ * example, `10.50.0.0/24`, not `10.50.0.1/24`). Declared IPv4/IPv6 subnets
165
+ * require the corresponding address family to be enabled. Alchemy validates
166
+ * address containment and overlapping subnets before a destructive replacement,
167
+ * and compares IPv6 addresses by their network value rather than text.
168
+ * Invalid options fail with `InvalidNetworkOptions`, whose `path` identifies
169
+ * the rejected field (for example, `ipam.config[0].gateway`).
170
+ * Labels beginning with `alchemy::` are reserved for resource ownership and
171
+ * cannot be overridden by user-supplied labels.
172
+ *
173
+ * ### IPv6-only networks
174
+ * **Example:** Disable IPv4 address allocation
175
+ * ```typescript
176
+ * const network = yield* Docker.Network("ipv6-only", {
177
+ * enableIPv4: false,
178
+ * enableIPv6: true,
179
+ * });
180
+ * ```
181
+ *
182
+ * ### Dual-stack networks
183
+ * **Example:** Allocate addresses from both families on a bridge network
184
+ * ```typescript
185
+ * const network = yield* Docker.Network("dual-stack", {
186
+ * enableIPv6: true,
187
+ * ipam: {
188
+ * config: [
189
+ * { subnet: "10.51.0.0/24", gateway: "10.51.0.1" },
190
+ * { subnet: "fd51:3456:789a::/64", gateway: "fd51:3456:789a::1" },
191
+ * ],
192
+ * },
193
+ * });
194
+ * ```
195
+ *
196
+ * ### Driver-specific IPAM options
197
+ * **Example:** Pass IPAM driver-specific settings
198
+ * ```typescript
199
+ * const network = yield* Docker.Network("ipam-network", {
200
+ * ipam: {
201
+ * driver: "default",
202
+ * options: { "my-option": "my-value" },
203
+ * },
204
+ * });
205
+ * ```
206
+ *
207
+ * Network driver and IPAM driver options are passed to Docker without
208
+ * interpreting driver-specific semantics.
209
+ *
69
210
  * ### Adoption
70
211
  * **Example:** Adopt a pre-existing network
71
212
  * ```typescript
@@ -74,6 +215,33 @@ export interface Network extends Resource<
74
215
  * }).pipe(adopt(true));
75
216
  * ```
76
217
  *
218
+ * ### Looking up an existing network
219
+ * `Network.lookup` references a network Alchemy doesn't manage (created with
220
+ * the Docker CLI, Compose or another stack). It is read during plan/deploy,
221
+ * never created, updated or removed.
222
+ *
223
+ * **Example:** Attach a container to an existing network
224
+ * ```typescript
225
+ * const shared = Docker.Network.lookup({ name: "compose_default" });
226
+ * const api = yield* Docker.Container("Api", {
227
+ * image: "nginx:alpine",
228
+ * networks: [{ name: shared.name }],
229
+ * });
230
+ * ```
231
+ *
232
+ * ### Replacement
233
+ * Network creation settings are immutable. Changing them replaces the network.
234
+ * Docker refuses to delete a network while it has attached endpoints; Alchemy
235
+ * does not forcibly disconnect unrelated containers to complete replacement.
236
+ * A managed network deleted outside Alchemy is recreated on the next deploy.
237
+ * If another network takes over its name, Alchemy refuses to claim it: the
238
+ * recorded Docker ID must still match, even if the network's settings do.
239
+ * Replacement is not transactional: a custom network or IPAM driver may reject
240
+ * a configuration after Docker has already removed the old network. Alchemy
241
+ * validates known IPAM constraints ahead of time, but the network driver
242
+ * determines which settings take effect. Some drivers may accept options
243
+ * without applying them; inspect the resulting network for advanced settings.
244
+ *
77
245
  * @resource
78
246
  * @product Network
79
247
  */
@@ -87,15 +255,24 @@ export const NetworkProvider = () =>
87
255
 
88
256
  return Network.Provider.of({
89
257
  list: () => Effect.succeed([]),
258
+ lookup: Effect.fn(function* ({ name, context }) {
259
+ const info = yield* docker.network
260
+ .inspect(name, context)
261
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
262
+ return info ? toNetworkAttributes(info) : undefined;
263
+ }),
264
+
90
265
  read: Effect.fn(function* ({ id, instanceId, olds, output }) {
91
266
  const context = dockerEngineContextName(olds?.context);
92
- const name = yield* dockerPhysicalName(id, olds, instanceId);
267
+ const name = yield* networkName(id, olds, instanceId);
93
268
  const info = yield* docker.network
94
269
  .inspect(name, context)
95
270
  .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
96
271
  if (!info) return undefined;
97
272
  const attrs = toNetworkAttributes(info);
98
- if (output) return attrs;
273
+ // A different network may have taken over the same name after the
274
+ // managed network was removed out of band. Never silently adopt it.
275
+ if (output) return output.id === attrs.id ? attrs : Unowned(attrs);
99
276
  // Without prior state, only adopt a network that carries our branding;
100
277
  // anything else is foreign and gated behind `--adopt`.
101
278
  const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined);
@@ -103,10 +280,10 @@ export const NetworkProvider = () =>
103
280
  }),
104
281
  diff: Effect.fn(function* ({ id, output, instanceId, news, olds }) {
105
282
  if (!isResolved(news) || !output) return undefined;
283
+ const args = yield* makeNetworkArgs(id, news, instanceId);
106
284
  if (dockerEngineContextName(olds?.context) !== dockerEngineContextName(news?.context)) {
107
285
  return { action: "replace", deleteFirst: true };
108
286
  }
109
- const args = yield* makeNetworkArgs(id, news, instanceId);
110
287
  // Auto-generated names are engine-owned: the deployed name stays
111
288
  // authoritative even if the generator would name this id differently
112
289
  // today. Only an explicit user-provided name can force a replace.
@@ -114,16 +291,47 @@ export const NetworkProvider = () =>
114
291
  if (
115
292
  output.name !== desiredName ||
116
293
  output.driver !== args.driver ||
294
+ (output.internal ?? false) !== (args.internal ?? false) ||
295
+ (output.attachable ?? false) !== (args.attachable ?? false) ||
296
+ (output.enableIPv4 ?? true) !== (args.ipv4 ?? true) ||
117
297
  output.enableIPv6 !== args.ipv6 ||
298
+ !matchesOptions(output.driverOpts ?? {}, args.opt ?? {}) ||
299
+ hasRemovedOptions(olds?.driverOpts, news?.driverOpts) ||
300
+ !matchesIpam(
301
+ output.ipam ?? { driver: "default", options: {}, config: [] },
302
+ news?.ipam,
303
+ olds?.ipam,
304
+ ) ||
118
305
  // Compare only user labels; internal `alchemy::*` branding lives on
119
306
  // the observed network but must not drive replacement.
120
307
  !Equal.equals(stripInternalTags(output.labels), args.label ?? {})
121
308
  ) {
122
309
  return { action: "replace", deleteFirst: true };
123
310
  }
311
+ // The persisted attributes do not prove that the same physical
312
+ // network still exists. Another actor can delete it and reuse the
313
+ // name, so check the Docker ID even when props have not changed.
314
+ const current = yield* docker.network
315
+ .inspect(output.name, dockerEngineContextName(olds?.context))
316
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
317
+ if (current && current.Id !== output.id) {
318
+ return yield* new OwnedBySomeoneElse({
319
+ message:
320
+ `Network '${output.name}' was replaced outside Alchemy: ` +
321
+ `expected Docker ID ${output.id}, found ${current.Id}. ` +
322
+ "Choose a different name or explicitly resolve the ownership conflict.",
323
+ resourceType: "Docker.Network",
324
+ logicalId: id,
325
+ physicalName: output.name,
326
+ });
327
+ }
328
+ // Reconcile can recreate a missing network. An update avoids
329
+ // deleting a generation that has already disappeared.
330
+ if (!current) return { action: "update" };
124
331
  }),
125
332
  reconcile: Effect.fn(function* ({ output, id, instanceId, news }) {
126
333
  const context = dockerEngineContextName(news?.context);
334
+ const args = yield* makeNetworkArgs(id, news, instanceId);
127
335
  if (output) {
128
336
  const refreshed = yield* docker.network.inspect(output.id, context).pipe(
129
337
  Effect.map(toNetworkAttributes),
@@ -131,11 +339,10 @@ export const NetworkProvider = () =>
131
339
  );
132
340
  if (refreshed) return refreshed;
133
341
  }
134
- const args = yield* makeNetworkArgs(id, news, instanceId);
135
342
  const internalTags = yield* createInternalTags(id);
136
343
  const { stdout: createdId } = yield* docker.network.create({
137
344
  ...args,
138
- label: { ...internalTags, ...args.label },
345
+ label: { ...args.label, ...internalTags },
139
346
  context,
140
347
  });
141
348
  return toNetworkAttributes(yield* docker.network.inspect(createdId, context));
@@ -149,21 +356,236 @@ export const NetworkProvider = () =>
149
356
  }),
150
357
  );
151
358
 
359
+ /**
360
+ * Swarm (overlay) network names are capped at 63 characters, like service
361
+ * names; other drivers accept much longer ones. An overlay network over the
362
+ * cap could never be created, so capping it renames nothing.
363
+ */
364
+ const networkName = (id: string, props: NetworkProps | undefined, instanceId: string) =>
365
+ dockerPhysicalName(id, props, instanceId, props?.driver === "overlay" ? 63 : undefined);
366
+
367
+ /** Invalid user-supplied network settings, detected before Docker is called. */
368
+ export class InvalidNetworkOptions extends Data.TaggedError("InvalidNetworkOptions")<{
369
+ /** Property path in the declared Docker.Network configuration. */
370
+ readonly path: string;
371
+ /** Human-readable reason for rejecting the network configuration. */
372
+ readonly message: string;
373
+ }> {}
374
+
375
+ const invalidNetworkOption = (path: string, detail: string) =>
376
+ Effect.fail(new InvalidNetworkOptions({ path, message: `Docker.Network ${path}: ${detail}` }));
377
+
378
+ const invalidMapEntry = (entries: Record<string, string> | undefined) =>
379
+ Object.entries(entries ?? {}).find(
380
+ ([key, value]) => !key.trim() || key.includes("=") || !value.trim(),
381
+ );
382
+
383
+ // Effect's network schemas reject CIDRs with host bits set and normalize
384
+ // equivalent IPv6 spellings. Use the same semantics for input validation and
385
+ // observed-state comparisons rather than maintaining a second IP parser.
386
+ const parseNetwork = Schema.decodeUnknownResult(Schema.IpNetworkFromString);
387
+ const parseIpAddress = Schema.decodeUnknownResult(Schema.IpAddressFromString);
388
+
389
+ const sameNetwork = (left: string, right: string) => {
390
+ const a = parseNetwork(left);
391
+ const b = parseNetwork(right);
392
+ return Result.isSuccess(a) && Result.isSuccess(b) && Equal.equals(a.success, b.success);
393
+ };
394
+
395
+ const sameIpAddress = (left: string, right: string) => {
396
+ const a = parseIpAddress(left);
397
+ const b = parseIpAddress(right);
398
+ return Result.isSuccess(a) && Result.isSuccess(b) && Equal.equals(a.success, b.success);
399
+ };
400
+
401
+ const validateNetworkOptions = (props: NetworkProps | undefined) => {
402
+ if (props?.enableIPv4 === false && props.enableIPv6 !== true) {
403
+ return invalidNetworkOption("enableIPv4", "disabling IPv4 requires enableIPv6=true");
404
+ }
405
+ const reservedLabel = Object.keys(props?.labels ?? {}).find((key) => key.startsWith("alchemy::"));
406
+ if (reservedLabel !== undefined) {
407
+ return invalidNetworkOption(
408
+ `labels[${JSON.stringify(reservedLabel)}]`,
409
+ "the alchemy:: label prefix is reserved for resource ownership",
410
+ );
411
+ }
412
+ for (const [path, entries] of [
413
+ ["driverOpts", props?.driverOpts],
414
+ ["ipam.options", props?.ipam?.options],
415
+ ] as const) {
416
+ const invalid = invalidMapEntry(entries);
417
+ if (invalid) {
418
+ return invalidNetworkOption(
419
+ `${path}[${JSON.stringify(invalid[0])}]`,
420
+ "option names must be nonempty and exclude '=', and values must be nonempty",
421
+ );
422
+ }
423
+ }
424
+ if (props?.ipam?.driver !== undefined && !props.ipam.driver.trim()) {
425
+ return invalidNetworkOption("ipam.driver", "driver cannot be empty");
426
+ }
427
+ const subnets: Net.IpNetwork.IpNetwork[] = [];
428
+ for (const [index, entry] of (props?.ipam?.config ?? []).entries()) {
429
+ const path = `ipam.config[${index}]`;
430
+ const subnet = parseNetwork(entry.subnet);
431
+ if (Result.isFailure(subnet)) {
432
+ return invalidNetworkOption(`${path}.subnet`, subnet.failure.message);
433
+ }
434
+ if (Net.IpNetwork.isIpv4Network(subnet.success) && props?.enableIPv4 === false) {
435
+ return invalidNetworkOption(
436
+ `${path}.subnet`,
437
+ `IPv4 subnet ${entry.subnet} requires enableIPv4 to be enabled`,
438
+ );
439
+ }
440
+ if (Net.IpNetwork.isIpv6Network(subnet.success) && props?.enableIPv6 !== true) {
441
+ return invalidNetworkOption(
442
+ `${path}.subnet`,
443
+ `IPv6 subnet ${entry.subnet} requires enableIPv6=true`,
444
+ );
445
+ }
446
+ const overlapping = subnets.findIndex((previous) =>
447
+ Net.IpNetwork.overlaps(previous, subnet.success),
448
+ );
449
+ if (overlapping !== -1) {
450
+ return invalidNetworkOption(
451
+ `${path}.subnet`,
452
+ `subnet ${entry.subnet} overlaps ipam.config[${overlapping}].subnet`,
453
+ );
454
+ }
455
+ subnets.push(subnet.success);
456
+ if (entry.gateway !== undefined) {
457
+ const gateway = parseIpAddress(entry.gateway);
458
+ if (Result.isFailure(gateway)) {
459
+ return invalidNetworkOption(`${path}.gateway`, gateway.failure.message);
460
+ }
461
+ if (!Net.IpNetwork.contains(subnet.success, gateway.success)) {
462
+ return invalidNetworkOption(
463
+ `${path}.gateway`,
464
+ `gateway ${entry.gateway} must be inside subnet ${entry.subnet}`,
465
+ );
466
+ }
467
+ }
468
+ if (entry.ipRange !== undefined) {
469
+ const range = parseNetwork(entry.ipRange);
470
+ if (Result.isFailure(range)) {
471
+ return invalidNetworkOption(`${path}.ipRange`, range.failure.message);
472
+ }
473
+ if (!Net.IpNetwork.containsNetwork(subnet.success, range.success)) {
474
+ return invalidNetworkOption(
475
+ `${path}.ipRange`,
476
+ `range ${entry.ipRange} must be inside subnet ${entry.subnet}`,
477
+ );
478
+ }
479
+ }
480
+ const invalidAux = invalidMapEntry(entry.auxAddresses);
481
+ if (invalidAux) {
482
+ return invalidNetworkOption(
483
+ `${path}.auxAddresses[${JSON.stringify(invalidAux[0])}]`,
484
+ "reservation names must be nonempty and exclude '=', and addresses must be nonempty",
485
+ );
486
+ }
487
+ for (const [name, address] of Object.entries(entry.auxAddresses ?? {})) {
488
+ const auxPath = `${path}.auxAddresses[${JSON.stringify(name)}]`;
489
+ const reserved = parseIpAddress(address);
490
+ if (Result.isFailure(reserved)) {
491
+ return invalidNetworkOption(auxPath, reserved.failure.message);
492
+ }
493
+ if (!Net.IpNetwork.contains(subnet.success, reserved.success)) {
494
+ return invalidNetworkOption(
495
+ auxPath,
496
+ `address ${address} must be inside subnet ${entry.subnet}`,
497
+ );
498
+ }
499
+ }
500
+ }
501
+ return Effect.void;
502
+ };
503
+
152
504
  const makeNetworkArgs = (id: string, props: NetworkProps | undefined, instanceId: string) =>
153
- dockerPhysicalName(id, props, instanceId).pipe(
505
+ networkName(id, props, instanceId).pipe(
506
+ Effect.tap(() => validateNetworkOptions(props)),
154
507
  Effect.map((name): Parameters<Docker["Service"]["network"]["create"]>[0] => ({
155
508
  name,
156
509
  driver: props?.driver ?? "bridge",
510
+ internal: props?.internal,
511
+ attachable: props?.attachable,
512
+ ipv4: props?.enableIPv4,
157
513
  ipv6: props?.enableIPv6 ?? false,
514
+ opt: props?.driverOpts,
515
+ ipam: props?.ipam,
158
516
  label: props?.labels ?? {},
159
517
  })),
160
518
  );
161
519
 
520
+ const matchesOptions = (actual: Record<string, string>, desired: Record<string, string>) =>
521
+ Object.entries(desired).every(([key, value]) => actual[key] === value);
522
+
523
+ const hasRemovedOptions = (
524
+ previous: Record<string, string> | undefined,
525
+ current: Record<string, string> | undefined,
526
+ ) => Object.keys(previous ?? {}).some((key) => !(key in (current ?? {})));
527
+
528
+ const matchesIpam = (
529
+ observed: Network["Attributes"]["ipam"],
530
+ desired: NetworkIpam | undefined,
531
+ previous: NetworkIpam | undefined,
532
+ ) => {
533
+ if (desired?.driver !== undefined && observed.driver !== desired.driver) return false;
534
+ if (previous?.driver !== undefined && desired?.driver === undefined) return false;
535
+ if (!matchesOptions(observed.options, desired?.options ?? {})) return false;
536
+ if (hasRemovedOptions(previous?.options, desired?.options)) return false;
537
+ const config = desired?.config ?? [];
538
+ for (const entry of config) {
539
+ const live = observed.config.find((item) => sameNetwork(item.subnet, entry.subnet));
540
+ if (
541
+ !live ||
542
+ (entry.gateway !== undefined &&
543
+ (live.gateway === undefined || !sameIpAddress(live.gateway, entry.gateway))) ||
544
+ (entry.ipRange !== undefined &&
545
+ (live.ipRange === undefined || !sameNetwork(live.ipRange, entry.ipRange))) ||
546
+ !Object.entries(entry.auxAddresses ?? {}).every(
547
+ ([key, value]) =>
548
+ live.auxAddresses?.[key] !== undefined && sameIpAddress(live.auxAddresses[key], value),
549
+ )
550
+ ) {
551
+ return false;
552
+ }
553
+ }
554
+ // Docker auto-allocates IPAM entries when config is omitted. Compare only
555
+ // explicitly declared entries, but detect removal of formerly managed ones.
556
+ for (const old of previous?.config ?? []) {
557
+ const now = config.find((item) => sameNetwork(item.subnet, old.subnet));
558
+ if (
559
+ !now ||
560
+ (old.gateway !== undefined && now.gateway === undefined) ||
561
+ (old.ipRange !== undefined && now.ipRange === undefined) ||
562
+ hasRemovedOptions(old.auxAddresses, now.auxAddresses)
563
+ ) {
564
+ return false;
565
+ }
566
+ }
567
+ return true;
568
+ };
569
+
162
570
  export const toNetworkAttributes = (info: Docker.Network): Network["Attributes"] => ({
163
571
  id: info.Id,
164
572
  name: info.Name,
165
573
  driver: info.Driver,
574
+ internal: info.Internal ?? false,
575
+ attachable: info.Attachable ?? false,
576
+ enableIPv4: info.EnableIPv4 ?? true,
166
577
  enableIPv6: info.EnableIPv6,
578
+ driverOpts: info.Options ?? {},
579
+ ipam: {
580
+ driver: info.IPAM?.Driver ?? "default",
581
+ options: info.IPAM?.Options ?? {},
582
+ config: (info.IPAM?.Config ?? []).map((entry) => ({
583
+ subnet: entry.Subnet,
584
+ gateway: entry.Gateway,
585
+ ipRange: entry.IPRange,
586
+ auxAddresses: entry.AuxiliaryAddresses ?? {},
587
+ })),
588
+ },
167
589
  labels: info.Labels ?? {},
168
590
  createdAt: Date.parse(info.Created) || Date.now(),
169
591
  });