@alchemy.run/docker 2.0.0-beta.84 → 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.
- package/README.md +16 -0
- package/lib/Bootstrap.d.ts.map +1 -1
- package/lib/Bootstrap.js +2 -1
- package/lib/Bootstrap.js.map +1 -1
- package/lib/BuildCache.d.ts +87 -0
- package/lib/BuildCache.d.ts.map +1 -0
- package/lib/BuildCache.js +39 -0
- package/lib/BuildCache.js.map +1 -0
- package/lib/BuildHash.d.ts +20 -3
- package/lib/BuildHash.d.ts.map +1 -1
- package/lib/BuildHash.js +60 -21
- package/lib/BuildHash.js.map +1 -1
- package/lib/BuildPlan.d.ts +202 -0
- package/lib/BuildPlan.d.ts.map +1 -0
- package/lib/BuildPlan.js +514 -0
- package/lib/BuildPlan.js.map +1 -0
- package/lib/Container.d.ts +122 -1
- package/lib/Container.d.ts.map +1 -1
- package/lib/Container.js +232 -4
- package/lib/Container.js.map +1 -1
- package/lib/Context.d.ts +20 -1
- package/lib/Context.d.ts.map +1 -1
- package/lib/Context.js +15 -0
- package/lib/Context.js.map +1 -1
- package/lib/Docker.d.ts +113 -1
- package/lib/Docker.d.ts.map +1 -1
- package/lib/Docker.js +50 -8
- package/lib/Docker.js.map +1 -1
- package/lib/Dockerfile.d.ts +0 -2
- package/lib/Dockerfile.d.ts.map +1 -1
- package/lib/Dockerfile.js.map +1 -1
- package/lib/DockerfileParser.d.ts +125 -0
- package/lib/DockerfileParser.d.ts.map +1 -0
- package/lib/DockerfileParser.js +1040 -0
- package/lib/DockerfileParser.js.map +1 -0
- package/lib/HealthcheckCommand.d.ts.map +1 -1
- package/lib/HealthcheckCommand.js.map +1 -1
- package/lib/Image.d.ts +69 -13
- package/lib/Image.d.ts.map +1 -1
- package/lib/Image.js +59 -9
- package/lib/Image.js.map +1 -1
- package/lib/ImageArchive.d.ts +41 -0
- package/lib/ImageArchive.d.ts.map +1 -0
- package/lib/ImageArchive.js +54 -0
- package/lib/ImageArchive.js.map +1 -0
- package/lib/ImageSource.d.ts +151 -8
- package/lib/ImageSource.d.ts.map +1 -1
- package/lib/ImageSource.js +247 -72
- package/lib/ImageSource.js.map +1 -1
- package/lib/Network.d.ts +175 -3
- package/lib/Network.d.ts.map +1 -1
- package/lib/Network.js +296 -8
- package/lib/Network.js.map +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js.map +1 -1
- package/lib/Registry.d.ts.map +1 -1
- package/lib/Registry.js.map +1 -1
- package/lib/RegistryError.d.ts.map +1 -1
- package/lib/RegistryError.js.map +1 -1
- package/lib/RemoteImage.d.ts.map +1 -1
- package/lib/RemoteImage.js +1 -1
- package/lib/RemoteImage.js.map +1 -1
- package/lib/Service.d.ts +24 -3
- package/lib/Service.d.ts.map +1 -1
- package/lib/Service.js +154 -12
- package/lib/Service.js.map +1 -1
- package/lib/ServiceImage.d.ts.map +1 -1
- package/lib/ServiceImage.js.map +1 -1
- package/lib/ShellWord.d.ts +28 -0
- package/lib/ShellWord.d.ts.map +1 -0
- package/lib/ShellWord.js +301 -0
- package/lib/ShellWord.js.map +1 -0
- package/lib/Swarm.d.ts.map +1 -1
- package/lib/Swarm.js.map +1 -1
- package/lib/Volume.d.ts +24 -1
- package/lib/Volume.d.ts.map +1 -1
- package/lib/Volume.js +16 -0
- package/lib/Volume.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- package/package.json +9 -9
- package/src/Bootstrap.ts +2 -0
- package/src/BuildCache.ts +111 -0
- package/src/BuildHash.ts +85 -26
- package/src/BuildPlan.ts +758 -0
- package/src/Container.ts +361 -5
- package/src/Context.ts +27 -1
- package/src/Docker.ts +157 -9
- package/src/Dockerfile.ts +0 -2
- package/src/DockerfileParser.ts +1152 -0
- package/src/Image.ts +75 -15
- package/src/ImageArchive.ts +77 -0
- package/src/ImageSource.ts +391 -99
- package/src/Network.ts +432 -10
- package/src/RemoteImage.ts +1 -1
- package/src/Service.ts +246 -62
- package/src/ShellWord.ts +326 -0
- 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
|
|
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*
|
|
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
|
-
|
|
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: { ...
|
|
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
|
-
|
|
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
|
});
|
package/src/RemoteImage.ts
CHANGED
|
@@ -140,7 +140,7 @@ export const RemoteImageProvider = () =>
|
|
|
140
140
|
const inspectImage = (ref: string, context: string | undefined) =>
|
|
141
141
|
docker.image
|
|
142
142
|
.inspect(ref, context)
|
|
143
|
-
.pipe(Effect.catchIf(isMissingImageError, () => Effect.
|
|
143
|
+
.pipe(Effect.catchIf(isMissingImageError, () => Effect.undefined));
|
|
144
144
|
|
|
145
145
|
return RemoteImage.Provider.of({
|
|
146
146
|
list: () => Effect.succeed([]),
|