@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/lib/Network.d.ts CHANGED
@@ -2,6 +2,29 @@ import * as Provider from "alchemy/Provider";
2
2
  import { Resource } from "alchemy/Resource";
3
3
  import { Docker } from "./Docker.ts";
4
4
  import type { Providers } from "./Providers.ts";
5
+ /** Address allocation settings for one Docker network subnet. */
6
+ export interface NetworkIpamConfig {
7
+ /** IPv4 or IPv6 CIDR network prefix, without host bits. */
8
+ subnet: string;
9
+ /** Gateway address in the subnet's address family. Docker chooses one when omitted. */
10
+ gateway?: string;
11
+ /** CIDR sub-range from which Docker allocates container addresses. */
12
+ ipRange?: string;
13
+ /** Map of reservation names to IP addresses in the subnet's address family. */
14
+ auxAddresses?: Record<string, string>;
15
+ }
16
+ /** Docker network IP Address Management (IPAM) configuration. */
17
+ export interface NetworkIpam {
18
+ /** IPAM driver. @default "default" */
19
+ driver?: string;
20
+ /** IPAM driver-specific key/value options. */
21
+ options?: Record<string, string>;
22
+ /**
23
+ * Subnet configurations. Docker allocates a subnet automatically when omitted;
24
+ * drivers differ in how many explicit subnet entries they accept.
25
+ */
26
+ config?: NetworkIpamConfig[];
27
+ }
5
28
  export interface NetworkProps {
6
29
  /**
7
30
  * Docker network name.
@@ -11,9 +34,23 @@ export interface NetworkProps {
11
34
  name?: string;
12
35
  /** Network driver. @default "bridge" */
13
36
  driver?: "bridge" | "host" | "none" | "overlay" | "macvlan" | (string & {});
37
+ /**
38
+ * Restrict external connectivity through this network. Containers connected
39
+ * to other networks can still have an external route.
40
+ * @default false
41
+ */
42
+ internal?: boolean;
43
+ /** Allow standalone containers to attach to an overlay network. @default false */
44
+ attachable?: boolean;
45
+ /** Enable IPv4 address allocation. Explicit `false` requires IPv6. @default true */
46
+ enableIPv4?: boolean;
14
47
  /** Enable IPv6 on the network. @default false */
15
48
  enableIPv6?: boolean;
16
- /** Network labels. */
49
+ /** Network-driver-specific key/value options, as passed to Docker `--opt`. */
50
+ driverOpts?: Record<string, string>;
51
+ /** Explicit IPAM configuration. Docker allocates missing subnet/gateway values. */
52
+ ipam?: NetworkIpam;
53
+ /** User labels. The `alchemy::*` prefix is reserved for resource ownership. */
17
54
  labels?: Record<string, string>;
18
55
  /**
19
56
  * The engine the network is created on: a Docker context name, a
@@ -30,13 +67,40 @@ export interface Network extends Resource<"Docker.Network", NetworkProps, {
30
67
  name: string;
31
68
  /** Network driver. */
32
69
  driver: string;
70
+ /** Whether the network is isolated from external connectivity. */
71
+ internal: boolean;
72
+ /** Whether manual container attachment is allowed. */
73
+ attachable: boolean;
74
+ /** Whether IPv4 address allocation is enabled. */
75
+ enableIPv4: boolean;
33
76
  /** Whether IPv6 is enabled. */
34
77
  enableIPv6: boolean;
78
+ /** Driver options reported by Docker. */
79
+ driverOpts: Record<string, string>;
80
+ /** IPAM configuration reported by Docker (including automatically allocated values). */
81
+ ipam: {
82
+ driver: string;
83
+ options: Record<string, string>;
84
+ config: NetworkIpamConfig[];
85
+ };
35
86
  /** Labels reported by Docker. */
36
87
  labels: Record<string, string>;
37
88
  /** Creation timestamp in milliseconds since epoch. */
38
89
  createdAt: number;
39
- }, never, Providers> {
90
+ }, never, Providers, NetworkLookup> {
91
+ }
92
+ /**
93
+ * Props for {@link Network.lookup}: find a network this stack
94
+ * doesn't manage by its name.
95
+ */
96
+ export interface NetworkLookup {
97
+ /** Network name. */
98
+ name: string;
99
+ /**
100
+ * Docker context the engine is reached through.
101
+ * @default the current context
102
+ */
103
+ context?: string;
40
104
  }
41
105
  /**
42
106
  * A Docker network managed through the active Docker context.
@@ -44,7 +108,6 @@ export interface Network extends Resource<"Docker.Network", NetworkProps, {
44
108
  * Existing same-name networks are treated as foreign unless the engine is
45
109
  * explicitly allowed to adopt them with `--adopt` or `adopt(true)`.
46
110
  *
47
- *
48
111
  * ### Creating Networks
49
112
  * **Example:** Basic bridge network
50
113
  * ```typescript
@@ -53,6 +116,76 @@ export interface Network extends Resource<"Docker.Network", NetworkProps, {
53
116
  * });
54
117
  * ```
55
118
  *
119
+ * ### Isolation and address allocation
120
+ * **Example:** Internal bridge network with an explicit subnet and driver option
121
+ * ```typescript
122
+ * const network = yield* Docker.Network("private-network", {
123
+ * internal: true,
124
+ * attachable: true,
125
+ * enableIPv4: true,
126
+ * driverOpts: { "com.docker.network.driver.mtu": "1420" },
127
+ * ipam: {
128
+ * config: [{
129
+ * subnet: "10.50.0.0/24",
130
+ * gateway: "10.50.0.1",
131
+ * ipRange: "10.50.0.128/25",
132
+ * auxAddresses: { reserved: "10.50.0.5" },
133
+ * }],
134
+ * },
135
+ * });
136
+ * ```
137
+ *
138
+ * An internal network restricts external connectivity through that network;
139
+ * additional container network attachments can still provide external routes.
140
+ * If IPAM is omitted, Docker allocates a subnet and gateway. Alchemy does not
141
+ * treat those automatically assigned values as configuration drift.
142
+ * IPAM subnet and range values must be valid CIDR network prefixes (for
143
+ * example, `10.50.0.0/24`, not `10.50.0.1/24`). Declared IPv4/IPv6 subnets
144
+ * require the corresponding address family to be enabled. Alchemy validates
145
+ * address containment and overlapping subnets before a destructive replacement,
146
+ * and compares IPv6 addresses by their network value rather than text.
147
+ * Invalid options fail with `InvalidNetworkOptions`, whose `path` identifies
148
+ * the rejected field (for example, `ipam.config[0].gateway`).
149
+ * Labels beginning with `alchemy::` are reserved for resource ownership and
150
+ * cannot be overridden by user-supplied labels.
151
+ *
152
+ * ### IPv6-only networks
153
+ * **Example:** Disable IPv4 address allocation
154
+ * ```typescript
155
+ * const network = yield* Docker.Network("ipv6-only", {
156
+ * enableIPv4: false,
157
+ * enableIPv6: true,
158
+ * });
159
+ * ```
160
+ *
161
+ * ### Dual-stack networks
162
+ * **Example:** Allocate addresses from both families on a bridge network
163
+ * ```typescript
164
+ * const network = yield* Docker.Network("dual-stack", {
165
+ * enableIPv6: true,
166
+ * ipam: {
167
+ * config: [
168
+ * { subnet: "10.51.0.0/24", gateway: "10.51.0.1" },
169
+ * { subnet: "fd51:3456:789a::/64", gateway: "fd51:3456:789a::1" },
170
+ * ],
171
+ * },
172
+ * });
173
+ * ```
174
+ *
175
+ * ### Driver-specific IPAM options
176
+ * **Example:** Pass IPAM driver-specific settings
177
+ * ```typescript
178
+ * const network = yield* Docker.Network("ipam-network", {
179
+ * ipam: {
180
+ * driver: "default",
181
+ * options: { "my-option": "my-value" },
182
+ * },
183
+ * });
184
+ * ```
185
+ *
186
+ * Network driver and IPAM driver options are passed to Docker without
187
+ * interpreting driver-specific semantics.
188
+ *
56
189
  * ### Adoption
57
190
  * **Example:** Adopt a pre-existing network
58
191
  * ```typescript
@@ -61,10 +194,49 @@ export interface Network extends Resource<"Docker.Network", NetworkProps, {
61
194
  * }).pipe(adopt(true));
62
195
  * ```
63
196
  *
197
+ * ### Looking up an existing network
198
+ * `Network.lookup` references a network Alchemy doesn't manage (created with
199
+ * the Docker CLI, Compose or another stack). It is read during plan/deploy,
200
+ * never created, updated or removed.
201
+ *
202
+ * **Example:** Attach a container to an existing network
203
+ * ```typescript
204
+ * const shared = Docker.Network.lookup({ name: "compose_default" });
205
+ * const api = yield* Docker.Container("Api", {
206
+ * image: "nginx:alpine",
207
+ * networks: [{ name: shared.name }],
208
+ * });
209
+ * ```
210
+ *
211
+ * ### Replacement
212
+ * Network creation settings are immutable. Changing them replaces the network.
213
+ * Docker refuses to delete a network while it has attached endpoints; Alchemy
214
+ * does not forcibly disconnect unrelated containers to complete replacement.
215
+ * A managed network deleted outside Alchemy is recreated on the next deploy.
216
+ * If another network takes over its name, Alchemy refuses to claim it: the
217
+ * recorded Docker ID must still match, even if the network's settings do.
218
+ * Replacement is not transactional: a custom network or IPAM driver may reject
219
+ * a configuration after Docker has already removed the old network. Alchemy
220
+ * validates known IPAM constraints ahead of time, but the network driver
221
+ * determines which settings take effect. Some drivers may accept options
222
+ * without applying them; inspect the resulting network for advanced settings.
223
+ *
64
224
  * @resource
65
225
  * @product Network
66
226
  */
67
227
  export declare const Network: import("alchemy/Resource").ResourceClass<Network>;
68
228
  export declare const NetworkProvider: () => import("effect/Layer").Layer<Provider.Provider<Network>, never, Docker | import("alchemy/Stack").Stack | import("alchemy/Stage").Stage>;
229
+ declare const InvalidNetworkOptions_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
230
+ readonly _tag: "InvalidNetworkOptions";
231
+ } & Readonly<A>;
232
+ /** Invalid user-supplied network settings, detected before Docker is called. */
233
+ export declare class InvalidNetworkOptions extends InvalidNetworkOptions_base<{
234
+ /** Property path in the declared Docker.Network configuration. */
235
+ readonly path: string;
236
+ /** Human-readable reason for rejecting the network configuration. */
237
+ readonly message: string;
238
+ }> {
239
+ }
69
240
  export declare const toNetworkAttributes: (info: Docker.Network) => Network["Attributes"];
241
+ export {};
70
242
  //# sourceMappingURL=Network.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Network.d.ts","sourceRoot":"","sources":["../src/Network.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAI5C,OAAO,EAAE,MAAM,EAA+C,MAAM,aAAa,CAAC;AAClF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,MAAM,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;IAC5E,iDAAiD;IACjD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,sBAAsB;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,SAAS,CAAC;CAC5B;AAED,MAAM,WAAW,OAAQ,SAAQ,QAAQ,CACvC,gBAAgB,EAChB,YAAY,EACZ;IACE,yBAAyB;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,UAAU,EAAE,OAAO,CAAC;IACpB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;CACnB,EACD,KAAK,EACL,SAAS,CACV;CAAG;AAEJ;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,OAAO,mDAAsC,CAAC;AAE3D,eAAO,MAAM,eAAe,+IAoEzB,CAAC;AAYJ,eAAO,MAAM,mBAAmB,SAAU,MAAM,CAAC,OAAO,KAAG,OAAO,CAAC,YAAY,CAO7E,CAAC"}
1
+ {"version":3,"file":"Network.d.ts","sources":["../src/Network.ts"],"names":[],"mappings":"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"}
package/lib/Network.js CHANGED
@@ -1,10 +1,14 @@
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.js";
9
13
  /**
10
14
  * A Docker network managed through the active Docker context.
@@ -12,7 +16,6 @@ import { Docker, dockerEngineContextName, dockerPhysicalName } from "./Docker.js
12
16
  * Existing same-name networks are treated as foreign unless the engine is
13
17
  * explicitly allowed to adopt them with `--adopt` or `adopt(true)`.
14
18
  *
15
- *
16
19
  * ### Creating Networks
17
20
  * **Example:** Basic bridge network
18
21
  * ```typescript
@@ -21,6 +24,76 @@ import { Docker, dockerEngineContextName, dockerPhysicalName } from "./Docker.js
21
24
  * });
22
25
  * ```
23
26
  *
27
+ * ### Isolation and address allocation
28
+ * **Example:** Internal bridge network with an explicit subnet and driver option
29
+ * ```typescript
30
+ * const network = yield* Docker.Network("private-network", {
31
+ * internal: true,
32
+ * attachable: true,
33
+ * enableIPv4: true,
34
+ * driverOpts: { "com.docker.network.driver.mtu": "1420" },
35
+ * ipam: {
36
+ * config: [{
37
+ * subnet: "10.50.0.0/24",
38
+ * gateway: "10.50.0.1",
39
+ * ipRange: "10.50.0.128/25",
40
+ * auxAddresses: { reserved: "10.50.0.5" },
41
+ * }],
42
+ * },
43
+ * });
44
+ * ```
45
+ *
46
+ * An internal network restricts external connectivity through that network;
47
+ * additional container network attachments can still provide external routes.
48
+ * If IPAM is omitted, Docker allocates a subnet and gateway. Alchemy does not
49
+ * treat those automatically assigned values as configuration drift.
50
+ * IPAM subnet and range values must be valid CIDR network prefixes (for
51
+ * example, `10.50.0.0/24`, not `10.50.0.1/24`). Declared IPv4/IPv6 subnets
52
+ * require the corresponding address family to be enabled. Alchemy validates
53
+ * address containment and overlapping subnets before a destructive replacement,
54
+ * and compares IPv6 addresses by their network value rather than text.
55
+ * Invalid options fail with `InvalidNetworkOptions`, whose `path` identifies
56
+ * the rejected field (for example, `ipam.config[0].gateway`).
57
+ * Labels beginning with `alchemy::` are reserved for resource ownership and
58
+ * cannot be overridden by user-supplied labels.
59
+ *
60
+ * ### IPv6-only networks
61
+ * **Example:** Disable IPv4 address allocation
62
+ * ```typescript
63
+ * const network = yield* Docker.Network("ipv6-only", {
64
+ * enableIPv4: false,
65
+ * enableIPv6: true,
66
+ * });
67
+ * ```
68
+ *
69
+ * ### Dual-stack networks
70
+ * **Example:** Allocate addresses from both families on a bridge network
71
+ * ```typescript
72
+ * const network = yield* Docker.Network("dual-stack", {
73
+ * enableIPv6: true,
74
+ * ipam: {
75
+ * config: [
76
+ * { subnet: "10.51.0.0/24", gateway: "10.51.0.1" },
77
+ * { subnet: "fd51:3456:789a::/64", gateway: "fd51:3456:789a::1" },
78
+ * ],
79
+ * },
80
+ * });
81
+ * ```
82
+ *
83
+ * ### Driver-specific IPAM options
84
+ * **Example:** Pass IPAM driver-specific settings
85
+ * ```typescript
86
+ * const network = yield* Docker.Network("ipam-network", {
87
+ * ipam: {
88
+ * driver: "default",
89
+ * options: { "my-option": "my-value" },
90
+ * },
91
+ * });
92
+ * ```
93
+ *
94
+ * Network driver and IPAM driver options are passed to Docker without
95
+ * interpreting driver-specific semantics.
96
+ *
24
97
  * ### Adoption
25
98
  * **Example:** Adopt a pre-existing network
26
99
  * ```typescript
@@ -29,6 +102,33 @@ import { Docker, dockerEngineContextName, dockerPhysicalName } from "./Docker.js
29
102
  * }).pipe(adopt(true));
30
103
  * ```
31
104
  *
105
+ * ### Looking up an existing network
106
+ * `Network.lookup` references a network Alchemy doesn't manage (created with
107
+ * the Docker CLI, Compose or another stack). It is read during plan/deploy,
108
+ * never created, updated or removed.
109
+ *
110
+ * **Example:** Attach a container to an existing network
111
+ * ```typescript
112
+ * const shared = Docker.Network.lookup({ name: "compose_default" });
113
+ * const api = yield* Docker.Container("Api", {
114
+ * image: "nginx:alpine",
115
+ * networks: [{ name: shared.name }],
116
+ * });
117
+ * ```
118
+ *
119
+ * ### Replacement
120
+ * Network creation settings are immutable. Changing them replaces the network.
121
+ * Docker refuses to delete a network while it has attached endpoints; Alchemy
122
+ * does not forcibly disconnect unrelated containers to complete replacement.
123
+ * A managed network deleted outside Alchemy is recreated on the next deploy.
124
+ * If another network takes over its name, Alchemy refuses to claim it: the
125
+ * recorded Docker ID must still match, even if the network's settings do.
126
+ * Replacement is not transactional: a custom network or IPAM driver may reject
127
+ * a configuration after Docker has already removed the old network. Alchemy
128
+ * validates known IPAM constraints ahead of time, but the network driver
129
+ * determines which settings take effect. Some drivers may accept options
130
+ * without applying them; inspect the resulting network for advanced settings.
131
+ *
32
132
  * @resource
33
133
  * @product Network
34
134
  */
@@ -37,17 +137,25 @@ export const NetworkProvider = () => Provider.effect(Network, Effect.gen(functio
37
137
  const docker = yield* Docker;
38
138
  return Network.Provider.of({
39
139
  list: () => Effect.succeed([]),
140
+ lookup: Effect.fn(function* ({ name, context }) {
141
+ const info = yield* docker.network
142
+ .inspect(name, context)
143
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
144
+ return info ? toNetworkAttributes(info) : undefined;
145
+ }),
40
146
  read: Effect.fn(function* ({ id, instanceId, olds, output }) {
41
147
  const context = dockerEngineContextName(olds?.context);
42
- const name = yield* dockerPhysicalName(id, olds, instanceId);
148
+ const name = yield* networkName(id, olds, instanceId);
43
149
  const info = yield* docker.network
44
150
  .inspect(name, context)
45
151
  .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
46
152
  if (!info)
47
153
  return undefined;
48
154
  const attrs = toNetworkAttributes(info);
155
+ // A different network may have taken over the same name after the
156
+ // managed network was removed out of band. Never silently adopt it.
49
157
  if (output)
50
- return attrs;
158
+ return output.id === attrs.id ? attrs : Unowned(attrs);
51
159
  // Without prior state, only adopt a network that carries our branding;
52
160
  // anything else is foreign and gated behind `--adopt`.
53
161
  const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined);
@@ -56,35 +164,61 @@ export const NetworkProvider = () => Provider.effect(Network, Effect.gen(functio
56
164
  diff: Effect.fn(function* ({ id, output, instanceId, news, olds }) {
57
165
  if (!isResolved(news) || !output)
58
166
  return undefined;
167
+ const args = yield* makeNetworkArgs(id, news, instanceId);
59
168
  if (dockerEngineContextName(olds?.context) !== dockerEngineContextName(news?.context)) {
60
169
  return { action: "replace", deleteFirst: true };
61
170
  }
62
- const args = yield* makeNetworkArgs(id, news, instanceId);
63
171
  // Auto-generated names are engine-owned: the deployed name stays
64
172
  // authoritative even if the generator would name this id differently
65
173
  // today. Only an explicit user-provided name can force a replace.
66
174
  const desiredName = news?.name ?? output.name;
67
175
  if (output.name !== desiredName ||
68
176
  output.driver !== args.driver ||
177
+ (output.internal ?? false) !== (args.internal ?? false) ||
178
+ (output.attachable ?? false) !== (args.attachable ?? false) ||
179
+ (output.enableIPv4 ?? true) !== (args.ipv4 ?? true) ||
69
180
  output.enableIPv6 !== args.ipv6 ||
181
+ !matchesOptions(output.driverOpts ?? {}, args.opt ?? {}) ||
182
+ hasRemovedOptions(olds?.driverOpts, news?.driverOpts) ||
183
+ !matchesIpam(output.ipam ?? { driver: "default", options: {}, config: [] }, news?.ipam, olds?.ipam) ||
70
184
  // Compare only user labels; internal `alchemy::*` branding lives on
71
185
  // the observed network but must not drive replacement.
72
186
  !Equal.equals(stripInternalTags(output.labels), args.label ?? {})) {
73
187
  return { action: "replace", deleteFirst: true };
74
188
  }
189
+ // The persisted attributes do not prove that the same physical
190
+ // network still exists. Another actor can delete it and reuse the
191
+ // name, so check the Docker ID even when props have not changed.
192
+ const current = yield* docker.network
193
+ .inspect(output.name, dockerEngineContextName(olds?.context))
194
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
195
+ if (current && current.Id !== output.id) {
196
+ return yield* new OwnedBySomeoneElse({
197
+ message: `Network '${output.name}' was replaced outside Alchemy: ` +
198
+ `expected Docker ID ${output.id}, found ${current.Id}. ` +
199
+ "Choose a different name or explicitly resolve the ownership conflict.",
200
+ resourceType: "Docker.Network",
201
+ logicalId: id,
202
+ physicalName: output.name,
203
+ });
204
+ }
205
+ // Reconcile can recreate a missing network. An update avoids
206
+ // deleting a generation that has already disappeared.
207
+ if (!current)
208
+ return { action: "update" };
75
209
  }),
76
210
  reconcile: Effect.fn(function* ({ output, id, instanceId, news }) {
77
211
  const context = dockerEngineContextName(news?.context);
212
+ const args = yield* makeNetworkArgs(id, news, instanceId);
78
213
  if (output) {
79
214
  const refreshed = yield* docker.network.inspect(output.id, context).pipe(Effect.map(toNetworkAttributes), Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
80
215
  if (refreshed)
81
216
  return refreshed;
82
217
  }
83
- const args = yield* makeNetworkArgs(id, news, instanceId);
84
218
  const internalTags = yield* createInternalTags(id);
85
219
  const { stdout: createdId } = yield* docker.network.create({
86
220
  ...args,
87
- label: { ...internalTags, ...args.label },
221
+ label: { ...args.label, ...internalTags },
88
222
  context,
89
223
  });
90
224
  return toNetworkAttributes(yield* docker.network.inspect(createdId, context));
@@ -94,17 +228,171 @@ export const NetworkProvider = () => Provider.effect(Network, Effect.gen(functio
94
228
  .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void))),
95
229
  });
96
230
  }));
97
- const makeNetworkArgs = (id, props, instanceId) => dockerPhysicalName(id, props, instanceId).pipe(Effect.map((name) => ({
231
+ /**
232
+ * Swarm (overlay) network names are capped at 63 characters, like service
233
+ * names; other drivers accept much longer ones. An overlay network over the
234
+ * cap could never be created, so capping it renames nothing.
235
+ */
236
+ const networkName = (id, props, instanceId) => dockerPhysicalName(id, props, instanceId, props?.driver === "overlay" ? 63 : undefined);
237
+ /** Invalid user-supplied network settings, detected before Docker is called. */
238
+ export class InvalidNetworkOptions extends Data.TaggedError("InvalidNetworkOptions") {
239
+ }
240
+ const invalidNetworkOption = (path, detail) => Effect.fail(new InvalidNetworkOptions({ path, message: `Docker.Network ${path}: ${detail}` }));
241
+ const invalidMapEntry = (entries) => Object.entries(entries ?? {}).find(([key, value]) => !key.trim() || key.includes("=") || !value.trim());
242
+ // Effect's network schemas reject CIDRs with host bits set and normalize
243
+ // equivalent IPv6 spellings. Use the same semantics for input validation and
244
+ // observed-state comparisons rather than maintaining a second IP parser.
245
+ const parseNetwork = Schema.decodeUnknownResult(Schema.IpNetworkFromString);
246
+ const parseIpAddress = Schema.decodeUnknownResult(Schema.IpAddressFromString);
247
+ const sameNetwork = (left, right) => {
248
+ const a = parseNetwork(left);
249
+ const b = parseNetwork(right);
250
+ return Result.isSuccess(a) && Result.isSuccess(b) && Equal.equals(a.success, b.success);
251
+ };
252
+ const sameIpAddress = (left, right) => {
253
+ const a = parseIpAddress(left);
254
+ const b = parseIpAddress(right);
255
+ return Result.isSuccess(a) && Result.isSuccess(b) && Equal.equals(a.success, b.success);
256
+ };
257
+ const validateNetworkOptions = (props) => {
258
+ if (props?.enableIPv4 === false && props.enableIPv6 !== true) {
259
+ return invalidNetworkOption("enableIPv4", "disabling IPv4 requires enableIPv6=true");
260
+ }
261
+ const reservedLabel = Object.keys(props?.labels ?? {}).find((key) => key.startsWith("alchemy::"));
262
+ if (reservedLabel !== undefined) {
263
+ return invalidNetworkOption(`labels[${JSON.stringify(reservedLabel)}]`, "the alchemy:: label prefix is reserved for resource ownership");
264
+ }
265
+ for (const [path, entries] of [
266
+ ["driverOpts", props?.driverOpts],
267
+ ["ipam.options", props?.ipam?.options],
268
+ ]) {
269
+ const invalid = invalidMapEntry(entries);
270
+ if (invalid) {
271
+ return invalidNetworkOption(`${path}[${JSON.stringify(invalid[0])}]`, "option names must be nonempty and exclude '=', and values must be nonempty");
272
+ }
273
+ }
274
+ if (props?.ipam?.driver !== undefined && !props.ipam.driver.trim()) {
275
+ return invalidNetworkOption("ipam.driver", "driver cannot be empty");
276
+ }
277
+ const subnets = [];
278
+ for (const [index, entry] of (props?.ipam?.config ?? []).entries()) {
279
+ const path = `ipam.config[${index}]`;
280
+ const subnet = parseNetwork(entry.subnet);
281
+ if (Result.isFailure(subnet)) {
282
+ return invalidNetworkOption(`${path}.subnet`, subnet.failure.message);
283
+ }
284
+ if (Net.IpNetwork.isIpv4Network(subnet.success) && props?.enableIPv4 === false) {
285
+ return invalidNetworkOption(`${path}.subnet`, `IPv4 subnet ${entry.subnet} requires enableIPv4 to be enabled`);
286
+ }
287
+ if (Net.IpNetwork.isIpv6Network(subnet.success) && props?.enableIPv6 !== true) {
288
+ return invalidNetworkOption(`${path}.subnet`, `IPv6 subnet ${entry.subnet} requires enableIPv6=true`);
289
+ }
290
+ const overlapping = subnets.findIndex((previous) => Net.IpNetwork.overlaps(previous, subnet.success));
291
+ if (overlapping !== -1) {
292
+ return invalidNetworkOption(`${path}.subnet`, `subnet ${entry.subnet} overlaps ipam.config[${overlapping}].subnet`);
293
+ }
294
+ subnets.push(subnet.success);
295
+ if (entry.gateway !== undefined) {
296
+ const gateway = parseIpAddress(entry.gateway);
297
+ if (Result.isFailure(gateway)) {
298
+ return invalidNetworkOption(`${path}.gateway`, gateway.failure.message);
299
+ }
300
+ if (!Net.IpNetwork.contains(subnet.success, gateway.success)) {
301
+ return invalidNetworkOption(`${path}.gateway`, `gateway ${entry.gateway} must be inside subnet ${entry.subnet}`);
302
+ }
303
+ }
304
+ if (entry.ipRange !== undefined) {
305
+ const range = parseNetwork(entry.ipRange);
306
+ if (Result.isFailure(range)) {
307
+ return invalidNetworkOption(`${path}.ipRange`, range.failure.message);
308
+ }
309
+ if (!Net.IpNetwork.containsNetwork(subnet.success, range.success)) {
310
+ return invalidNetworkOption(`${path}.ipRange`, `range ${entry.ipRange} must be inside subnet ${entry.subnet}`);
311
+ }
312
+ }
313
+ const invalidAux = invalidMapEntry(entry.auxAddresses);
314
+ if (invalidAux) {
315
+ return invalidNetworkOption(`${path}.auxAddresses[${JSON.stringify(invalidAux[0])}]`, "reservation names must be nonempty and exclude '=', and addresses must be nonempty");
316
+ }
317
+ for (const [name, address] of Object.entries(entry.auxAddresses ?? {})) {
318
+ const auxPath = `${path}.auxAddresses[${JSON.stringify(name)}]`;
319
+ const reserved = parseIpAddress(address);
320
+ if (Result.isFailure(reserved)) {
321
+ return invalidNetworkOption(auxPath, reserved.failure.message);
322
+ }
323
+ if (!Net.IpNetwork.contains(subnet.success, reserved.success)) {
324
+ return invalidNetworkOption(auxPath, `address ${address} must be inside subnet ${entry.subnet}`);
325
+ }
326
+ }
327
+ }
328
+ return Effect.void;
329
+ };
330
+ const makeNetworkArgs = (id, props, instanceId) => networkName(id, props, instanceId).pipe(Effect.tap(() => validateNetworkOptions(props)), Effect.map((name) => ({
98
331
  name,
99
332
  driver: props?.driver ?? "bridge",
333
+ internal: props?.internal,
334
+ attachable: props?.attachable,
335
+ ipv4: props?.enableIPv4,
100
336
  ipv6: props?.enableIPv6 ?? false,
337
+ opt: props?.driverOpts,
338
+ ipam: props?.ipam,
101
339
  label: props?.labels ?? {},
102
340
  })));
341
+ const matchesOptions = (actual, desired) => Object.entries(desired).every(([key, value]) => actual[key] === value);
342
+ const hasRemovedOptions = (previous, current) => Object.keys(previous ?? {}).some((key) => !(key in (current ?? {})));
343
+ const matchesIpam = (observed, desired, previous) => {
344
+ if (desired?.driver !== undefined && observed.driver !== desired.driver)
345
+ return false;
346
+ if (previous?.driver !== undefined && desired?.driver === undefined)
347
+ return false;
348
+ if (!matchesOptions(observed.options, desired?.options ?? {}))
349
+ return false;
350
+ if (hasRemovedOptions(previous?.options, desired?.options))
351
+ return false;
352
+ const config = desired?.config ?? [];
353
+ for (const entry of config) {
354
+ const live = observed.config.find((item) => sameNetwork(item.subnet, entry.subnet));
355
+ if (!live ||
356
+ (entry.gateway !== undefined &&
357
+ (live.gateway === undefined || !sameIpAddress(live.gateway, entry.gateway))) ||
358
+ (entry.ipRange !== undefined &&
359
+ (live.ipRange === undefined || !sameNetwork(live.ipRange, entry.ipRange))) ||
360
+ !Object.entries(entry.auxAddresses ?? {}).every(([key, value]) => live.auxAddresses?.[key] !== undefined && sameIpAddress(live.auxAddresses[key], value))) {
361
+ return false;
362
+ }
363
+ }
364
+ // Docker auto-allocates IPAM entries when config is omitted. Compare only
365
+ // explicitly declared entries, but detect removal of formerly managed ones.
366
+ for (const old of previous?.config ?? []) {
367
+ const now = config.find((item) => sameNetwork(item.subnet, old.subnet));
368
+ if (!now ||
369
+ (old.gateway !== undefined && now.gateway === undefined) ||
370
+ (old.ipRange !== undefined && now.ipRange === undefined) ||
371
+ hasRemovedOptions(old.auxAddresses, now.auxAddresses)) {
372
+ return false;
373
+ }
374
+ }
375
+ return true;
376
+ };
103
377
  export const toNetworkAttributes = (info) => ({
104
378
  id: info.Id,
105
379
  name: info.Name,
106
380
  driver: info.Driver,
381
+ internal: info.Internal ?? false,
382
+ attachable: info.Attachable ?? false,
383
+ enableIPv4: info.EnableIPv4 ?? true,
107
384
  enableIPv6: info.EnableIPv6,
385
+ driverOpts: info.Options ?? {},
386
+ ipam: {
387
+ driver: info.IPAM?.Driver ?? "default",
388
+ options: info.IPAM?.Options ?? {},
389
+ config: (info.IPAM?.Config ?? []).map((entry) => ({
390
+ subnet: entry.Subnet,
391
+ gateway: entry.Gateway,
392
+ ipRange: entry.IPRange,
393
+ auxAddresses: entry.AuxiliaryAddresses ?? {},
394
+ })),
395
+ },
108
396
  labels: info.Labels ?? {},
109
397
  createdAt: Date.parse(info.Created) || Date.now(),
110
398
  });