@alchemy.run/docker 0.0.0-stage → 2.0.0-beta.84

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 (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +50 -2
  3. package/lib/Bootstrap.d.ts +7 -0
  4. package/lib/Bootstrap.d.ts.map +1 -0
  5. package/lib/Bootstrap.js +25 -0
  6. package/lib/Bootstrap.js.map +1 -0
  7. package/lib/BuildHash.d.ts +47 -0
  8. package/lib/BuildHash.d.ts.map +1 -0
  9. package/lib/BuildHash.js +165 -0
  10. package/lib/BuildHash.js.map +1 -0
  11. package/lib/Container.d.ts +352 -0
  12. package/lib/Container.d.ts.map +1 -0
  13. package/lib/Container.js +531 -0
  14. package/lib/Container.js.map +1 -0
  15. package/lib/Context.d.ts +86 -0
  16. package/lib/Context.d.ts.map +1 -0
  17. package/lib/Context.js +146 -0
  18. package/lib/Context.js.map +1 -0
  19. package/lib/Docker.d.ts +448 -0
  20. package/lib/Docker.d.ts.map +1 -0
  21. package/lib/Docker.js +416 -0
  22. package/lib/Docker.js.map +1 -0
  23. package/lib/Dockerfile.d.ts +72 -0
  24. package/lib/Dockerfile.d.ts.map +1 -0
  25. package/lib/Dockerfile.js +12 -0
  26. package/lib/Dockerfile.js.map +1 -0
  27. package/lib/HealthcheckCommand.d.ts +17 -0
  28. package/lib/HealthcheckCommand.d.ts.map +1 -0
  29. package/lib/HealthcheckCommand.js +33 -0
  30. package/lib/HealthcheckCommand.js.map +1 -0
  31. package/lib/Image.d.ts +151 -0
  32. package/lib/Image.d.ts.map +1 -0
  33. package/lib/Image.js +192 -0
  34. package/lib/Image.js.map +1 -0
  35. package/lib/ImageSource.d.ts +234 -0
  36. package/lib/ImageSource.d.ts.map +1 -0
  37. package/lib/ImageSource.js +469 -0
  38. package/lib/ImageSource.js.map +1 -0
  39. package/lib/Network.d.ts +70 -0
  40. package/lib/Network.d.ts.map +1 -0
  41. package/lib/Network.js +111 -0
  42. package/lib/Network.js.map +1 -0
  43. package/lib/Providers.d.ts +21 -0
  44. package/lib/Providers.d.ts.map +1 -0
  45. package/lib/Providers.js +26 -0
  46. package/lib/Providers.js.map +1 -0
  47. package/lib/Registry.d.ts +47 -0
  48. package/lib/Registry.d.ts.map +1 -0
  49. package/lib/Registry.js +67 -0
  50. package/lib/Registry.js.map +1 -0
  51. package/lib/RegistryError.d.ts +21 -0
  52. package/lib/RegistryError.d.ts.map +1 -0
  53. package/lib/RegistryError.js +28 -0
  54. package/lib/RegistryError.js.map +1 -0
  55. package/lib/RemoteImage.d.ts +118 -0
  56. package/lib/RemoteImage.d.ts.map +1 -0
  57. package/lib/RemoteImage.js +164 -0
  58. package/lib/RemoteImage.js.map +1 -0
  59. package/lib/Service.d.ts +478 -0
  60. package/lib/Service.d.ts.map +1 -0
  61. package/lib/Service.js +588 -0
  62. package/lib/Service.js.map +1 -0
  63. package/lib/ServiceImage.d.ts +78 -0
  64. package/lib/ServiceImage.d.ts.map +1 -0
  65. package/lib/ServiceImage.js +162 -0
  66. package/lib/ServiceImage.js.map +1 -0
  67. package/lib/Swarm.d.ts +119 -0
  68. package/lib/Swarm.d.ts.map +1 -0
  69. package/lib/Swarm.js +148 -0
  70. package/lib/Swarm.js.map +1 -0
  71. package/lib/Volume.d.ts +94 -0
  72. package/lib/Volume.d.ts.map +1 -0
  73. package/lib/Volume.js +133 -0
  74. package/lib/Volume.js.map +1 -0
  75. package/lib/index.d.ts +14 -0
  76. package/lib/index.d.ts.map +1 -0
  77. package/lib/index.js +13 -0
  78. package/lib/index.js.map +1 -0
  79. package/package.json +72 -3
  80. package/src/Bootstrap.ts +51 -0
  81. package/src/BuildHash.ts +255 -0
  82. package/src/Container.ts +792 -0
  83. package/src/Context.ts +215 -0
  84. package/src/Docker.ts +1079 -0
  85. package/src/Dockerfile.ts +81 -0
  86. package/src/HealthcheckCommand.ts +35 -0
  87. package/src/Image.ts +293 -0
  88. package/src/ImageSource.ts +769 -0
  89. package/src/Network.ts +169 -0
  90. package/src/Providers.ts +46 -0
  91. package/src/Registry.ts +91 -0
  92. package/src/RegistryError.ts +48 -0
  93. package/src/RemoteImage.ts +255 -0
  94. package/src/Service.ts +1080 -0
  95. package/src/ServiceImage.ts +252 -0
  96. package/src/Swarm.ts +221 -0
  97. package/src/Volume.ts +193 -0
  98. package/src/index.ts +17 -0
@@ -0,0 +1,252 @@
1
+ import { AlchemyContext } from "alchemy/AlchemyContext";
2
+ import * as Bundle from "alchemy/Bundle/Bundle";
3
+ import { findCwdForBundle, getStableContextDir, resolveMainPath } from "alchemy/Bundle/TempRoot";
4
+ import { sha256Object } from "alchemy/Util/sha256";
5
+ import * as Effect from "effect/Effect";
6
+ import type * as rolldown from "rolldown";
7
+ import { Docker } from "./Docker.ts";
8
+
9
+ /**
10
+ * INTERNAL — image machinery for the effectful `Docker.Service` platform.
11
+ *
12
+ * NOT exported from the Docker barrel. Bundles a `main` Effect program with
13
+ * rolldown, bakes it into a generated bun Dockerfile, and builds the image
14
+ * with the Docker CLI against the service's Docker context — so the image is
15
+ * present in the exact engine the Swarm service schedules from. There is no
16
+ * registry push: the built image is content-addressed (`<name>:<hash>`) and
17
+ * only rebuilt when the bundle, Dockerfile, or environment image changes.
18
+ *
19
+ * Multi-node swarms need the image on every node; for those, build with
20
+ * `Docker.Image` + a `registry` and pass the pushed ref as `image` instead.
21
+ */
22
+
23
+ /** The props subset that describes a bundled `main` service image. */
24
+ export interface BundledServiceSource {
25
+ main: string;
26
+ image?: string;
27
+ handler?: string;
28
+ port?: number;
29
+ /**
30
+ * Bundler configuration for `main`. Unused code is tree-shaken.
31
+ * `effect`, alchemy, and `@distilled.cloud` are marked pure so unused
32
+ * parts prune more aggressively. List extra packages with
33
+ * `pure.packages`, or disable with `pure: false`.
34
+ */
35
+ build?: Bundle.BundleConfig;
36
+ }
37
+
38
+ /** The resolved (built) local image. */
39
+ export interface ResolvedServiceImage {
40
+ /** Full image reference, `<name>:<codeHash>`. */
41
+ imageRef: string;
42
+ /** Content hash identifying the image (also the image tag). */
43
+ codeHash: string;
44
+ }
45
+
46
+ /**
47
+ * The generated entry for `Docker.Service` containers: a shim importing only
48
+ * `@alchemy.run/docker/Bootstrap` plus the user's `main` — see that
49
+ * module for why the entry never imports alchemy's own dependencies.
50
+ */
51
+ export const makeServiceBunBootstrap =
52
+ (handler: string) =>
53
+ (importPath: string): string =>
54
+ `
55
+ import { bootstrap } from "@alchemy.run/docker/Bootstrap";
56
+ import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
57
+
58
+ await bootstrap(entrypoint);
59
+ `;
60
+
61
+ /**
62
+ * Init-time constructor for the service-image resolver. Resolves the services
63
+ * that are only available at provider-layer construction (Docker, the
64
+ * `.alchemy` directory, the rolldown virtual-entry plugin) and returns
65
+ * `resolve` (build the image if its content hash isn't present in the target
66
+ * engine) and `hash` (content hash only, for `diff`).
67
+ */
68
+ export const makeServiceImage = Effect.gen(function* () {
69
+ const docker = yield* Docker;
70
+ const { dotAlchemy } = yield* AlchemyContext;
71
+ const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
72
+
73
+ /** Bundle the Effect program behind a `main` source. */
74
+ const bundleProgram = Effect.fn(function* (options: {
75
+ source: BundledServiceSource;
76
+ isExternal?: boolean;
77
+ }) {
78
+ const { source } = options;
79
+ const realMain = yield* resolveMainPath(source.main);
80
+ const cwd = yield* findCwdForBundle(realMain);
81
+ const bootstrap = makeServiceBunBootstrap(source.handler ?? "default");
82
+
83
+ const buildBundle = Effect.fn(function* (
84
+ entry: string,
85
+ plugins?: rolldown.RolldownPluginOption,
86
+ ) {
87
+ return yield* Bundle.build(
88
+ {
89
+ ...source.build?.input,
90
+ input: entry,
91
+ cwd,
92
+ platform: "node",
93
+ // The container runs on `bun`; keep `bun`/`bun:*` external (the
94
+ // runtime provides them) and resolve the `bun` export condition
95
+ // so `@effect/platform-bun` picks its Bun implementations.
96
+ external: [
97
+ "bun",
98
+ "bun:*",
99
+ ...((source.build?.input?.external as string[] | undefined) ?? []),
100
+ ],
101
+ resolve: {
102
+ conditionNames: [...Bundle.BUN_CONDITION_NAMES],
103
+ ...source.build?.input?.resolve,
104
+ },
105
+ plugins: [source.build?.input?.plugins, plugins],
106
+ },
107
+ {
108
+ ...source.build?.output,
109
+ format: "esm",
110
+ sourcemap: source.build?.output?.sourcemap ?? false,
111
+ minify: source.build?.output?.minify ?? false,
112
+ entryFileNames: "index.mjs",
113
+ },
114
+ source.build,
115
+ );
116
+ });
117
+
118
+ const bundleOutput = options.isExternal
119
+ ? yield* buildBundle(realMain)
120
+ : yield* buildBundle(realMain, virtualEntryPlugin(bootstrap));
121
+
122
+ // Return every emitted file (entry + shared chunks). Dynamic imports in
123
+ // the Bun HTTP server split into chunks; dropping any of them crashes the
124
+ // container with `Cannot find module './chunk-XXX.js'`.
125
+ const files = bundleOutput.files.map((file) => ({
126
+ path: file.path,
127
+ content:
128
+ typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content,
129
+ }));
130
+
131
+ return { files, hash: bundleOutput.hash };
132
+ });
133
+
134
+ /**
135
+ * Generated Dockerfile for a bundled `main` program. The environment
136
+ * preamble is the `image` ref or the default bun base (`oven/bun` is
137
+ * Docker-Hub only).
138
+ */
139
+ const generateDockerfile = (source: BundledServiceSource) => {
140
+ const lines = [
141
+ `FROM ${source.image ?? "oven/bun:1"}`,
142
+ `WORKDIR /app`,
143
+ `COPY index.mjs /app/index.mjs`,
144
+ // Copy any additional rolldown chunks (`chunk-XXX.js`, …). Non-trivial
145
+ // bundles always emit at least one; minimal bundles emit none and the
146
+ // COPY no-ops.
147
+ `COPY *.js /app/`,
148
+ ];
149
+ if (source.port !== undefined) {
150
+ lines.push(`ENV PORT=${String(source.port)}`, `EXPOSE ${String(source.port)}`);
151
+ }
152
+ lines.push(`ENTRYPOINT ["bun", "/app/index.mjs"]`);
153
+ return `${lines.join("\n")}\n`;
154
+ };
155
+
156
+ /**
157
+ * Bundle a `main` source and compute its content-addressed code hash. The
158
+ * hash covers the full image identity — the bundle output (bootstrap entry
159
+ * included, so bootstrap-template changes invalidate it) and the generated
160
+ * Dockerfile — so `diff` and `resolve` always agree.
161
+ */
162
+ const computeCodeHash = Effect.fn(function* (options: {
163
+ source: BundledServiceSource;
164
+ isExternal?: boolean;
165
+ }) {
166
+ const bundled = yield* bundleProgram(options);
167
+ const dockerfile = generateDockerfile(options.source);
168
+ const codeHash = (yield* sha256Object({
169
+ bundleHash: bundled.hash,
170
+ dockerfile,
171
+ })).slice(0, 16);
172
+ return { bundled, dockerfile, codeHash };
173
+ });
174
+
175
+ /** Observe a locally-present tag in the target engine. Missing → undefined. */
176
+ const imageExists = (imageRef: string, context: string | undefined) =>
177
+ docker.image.inspect(imageRef, context).pipe(
178
+ Effect.map(() => true),
179
+ Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
180
+ );
181
+
182
+ /**
183
+ * Resolve the image for a bundled service: compute the content-addressed
184
+ * tag, then build only when that exact tag is not already present in the
185
+ * target Docker context (crash-safe convergence).
186
+ */
187
+ const resolve = Effect.fn(function* (options: {
188
+ /** Logical resource id — keys the stable build-context directory. */
189
+ id: string;
190
+ source: BundledServiceSource;
191
+ /** Image repository name (the service's physical name). */
192
+ name: string;
193
+ /** Docker context the swarm service is deployed to. */
194
+ context: string | undefined;
195
+ isExternal?: boolean;
196
+ /** Plan-status session used to emit build progress notes. */
197
+ session: { note: (message: string) => Effect.Effect<void> };
198
+ }) {
199
+ const { id, source, session } = options;
200
+ yield* session.note(`Bundling ${id} program...`);
201
+ const { bundled, dockerfile, codeHash } = yield* computeCodeHash({
202
+ source,
203
+ isExternal: options.isExternal,
204
+ });
205
+ const imageRef = `${options.name}:${codeHash}`;
206
+
207
+ if (yield* imageExists(imageRef, options.context)) {
208
+ return { imageRef, codeHash } satisfies ResolvedServiceImage;
209
+ }
210
+
211
+ const realMain = yield* resolveMainPath(source.main);
212
+ const contextDir = yield* getStableContextDir(realMain, dotAlchemy, `${id}-image`);
213
+ yield* docker.materialize({
214
+ context: contextDir,
215
+ dockerfile,
216
+ // Entry chunk becomes `index.mjs`; all other chunks keep their emitted
217
+ // `*.js` names so the entry's relative imports resolve.
218
+ files: bundled.files.map((file, index) => ({
219
+ path: index === 0 ? "index.mjs" : file.path,
220
+ content: file.content,
221
+ })),
222
+ });
223
+ yield* session.note(`Building container image ${imageRef}...`);
224
+ yield* docker.image.build({
225
+ context: contextDir,
226
+ tag: imageRef,
227
+ engineContext: options.context,
228
+ });
229
+ yield* session.note(`Built ${imageRef}`);
230
+ return { imageRef, codeHash } satisfies ResolvedServiceImage;
231
+ });
232
+
233
+ /**
234
+ * Content hash without building the image. Runs the bundler (bootstrap
235
+ * entry included) so the hash reflects the exact image `resolve` would
236
+ * build — bootstrap-template changes and user-code edits both surface as
237
+ * drift. The provider calls this from `diff` and compares against
238
+ * `output.code.hash`.
239
+ */
240
+ const hash = Effect.fn(function* (options: {
241
+ source: BundledServiceSource;
242
+ isExternal?: boolean;
243
+ }) {
244
+ const { codeHash } = yield* computeCodeHash(options);
245
+ return codeHash;
246
+ });
247
+
248
+ return { resolve, hash };
249
+ });
250
+
251
+ /** The resolver service returned by {@link makeServiceImage}. */
252
+ export interface ServiceImage extends Effect.Success<typeof makeServiceImage> {}
package/src/Swarm.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { Unowned } from "alchemy/AdoptPolicy";
2
+ import { isResolved } from "alchemy/Diff";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import * as Effect from "effect/Effect";
6
+ import { Docker, dockerContextName } from "./Docker.ts";
7
+ import type { Providers } from "./Providers.ts";
8
+
9
+ export interface SwarmProps {
10
+ /** Docker context name or context resource of the engine to initialize. */
11
+ context?: Docker.ContextRef;
12
+ /**
13
+ * Address advertised to other swarm members, e.g. `"10.0.0.1"` or
14
+ * `"eth0"`. Required by Docker when the host has several network
15
+ * interfaces. Applied only when the swarm is first initialized.
16
+ */
17
+ advertiseAddr?: string;
18
+ /**
19
+ * Listen address for swarm traffic, e.g. `"0.0.0.0:2377"`. Applied only
20
+ * when the swarm is first initialized.
21
+ */
22
+ listenAddr?: string;
23
+ /**
24
+ * Address pools for global-scope (overlay) networks, e.g.
25
+ * `["10.20.0.0/16"]`. Applied only when the swarm is first initialized.
26
+ */
27
+ defaultAddrPool?: string[];
28
+ /**
29
+ * CIDR mask length for each subnet carved from `defaultAddrPool`.
30
+ * Applied only when the swarm is first initialized.
31
+ *
32
+ * @default 24
33
+ */
34
+ subnetSize?: number;
35
+ }
36
+
37
+ export interface Swarm extends Resource<
38
+ "Docker.Swarm",
39
+ SwarmProps,
40
+ {
41
+ /** Swarm cluster id. */
42
+ id: string;
43
+ /** This node's id in the swarm (a manager). */
44
+ nodeId: string;
45
+ /** Docker context the swarm was initialized on. */
46
+ context?: string;
47
+ /** Number of manager nodes. */
48
+ managers: number;
49
+ /** Number of nodes in the swarm. */
50
+ nodes: number;
51
+ },
52
+ never,
53
+ Providers
54
+ > {}
55
+
56
+ /**
57
+ * Swarm mode on a Docker engine — an idempotent `docker swarm init`.
58
+ *
59
+ * Turns the engine behind the given `context` (or the local engine) into a
60
+ * single-node swarm: the node becomes a manager that can run `Docker.Service`
61
+ * workloads. Regular (non-swarm) docker usage of the engine is unaffected.
62
+ *
63
+ * Pass the swarm as the `context` of a `Docker.Service` or overlay
64
+ * `Docker.Network`: the workload then deploys after the swarm exists and
65
+ * inherits its Docker context.
66
+ *
67
+ * An engine that is already in swarm mode is treated as foreign — adopt it
68
+ * with `adopt(true)` (or `--adopt`) to manage it. Destroying the resource
69
+ * dissolves the node's swarm membership (`docker swarm leave --force`),
70
+ * which stops every service running on it — services managed by the same
71
+ * stack are destroyed first via their dependency edges.
72
+ *
73
+ * Growing the cluster beyond one node is host-level setup: run
74
+ * `docker swarm join --token <token> <manager-ip>:2377` on each additional
75
+ * machine (`docker swarm join-token worker` on the manager prints the
76
+ * command). Alchemy manages the swarm's workloads through the manager.
77
+ * ### Creating a Swarm
78
+ * **Example:** Local single-node swarm
79
+ * ```typescript
80
+ * const swarm = yield* Docker.Swarm("swarm");
81
+ * ```
82
+ *
83
+ * **Example:** Remote engine over SSH
84
+ * ```typescript
85
+ * const vps = yield* Docker.Context("vps", {
86
+ * docker: "host=ssh://deploy@example.com",
87
+ * });
88
+ * const swarm = yield* Docker.Swarm("swarm", {
89
+ * context: vps,
90
+ * advertiseAddr: "10.0.0.1",
91
+ * });
92
+ * ```
93
+ *
94
+ * ### Using an Existing Swarm
95
+ * **Example:** Reference without owning
96
+ * ```typescript
97
+ * // Services don't require a Swarm resource — point them at an engine that
98
+ * // is already a manager and the swarm's lifecycle stays external: destroy
99
+ * // removes the services, never the swarm.
100
+ * const web = yield* Docker.Service("web", {
101
+ * context: vps,
102
+ * image: "nginx:alpine",
103
+ * });
104
+ * ```
105
+ *
106
+ * **Example:** Adopt an already-initialized engine
107
+ * ```typescript
108
+ * // Adoption makes the swarm part of the stack — destroy then dissolves
109
+ * // the node's membership.
110
+ * const swarm = yield* Docker.Swarm("swarm").pipe(adopt(true));
111
+ * ```
112
+ *
113
+ * ### Deploying into the Swarm
114
+ * **Example:** Service ordered after the swarm
115
+ * ```typescript
116
+ * const swarm = yield* Docker.Swarm("swarm");
117
+ * const web = yield* Docker.Service("web", {
118
+ * context: swarm,
119
+ * image: "nginx:alpine",
120
+ * replicas: 2,
121
+ * ports: [{ external: 8080, internal: 80 }],
122
+ * });
123
+ * ```
124
+ *
125
+ * @resource
126
+ * @product Swarm
127
+ */
128
+ export const Swarm = Resource<Swarm>("Docker.Swarm");
129
+
130
+ export const SwarmProvider = () =>
131
+ Provider.effect(
132
+ Swarm,
133
+ Effect.gen(function* () {
134
+ const docker = yield* Docker;
135
+
136
+ /** Active-manager info, or undefined when the engine isn't one. */
137
+ const observe = Effect.fn(function* (context: string | undefined) {
138
+ const info = yield* docker.swarm.info(context);
139
+ if (info.LocalNodeState !== "active" || !info.ControlAvailable || !info.Cluster?.ID) {
140
+ return undefined;
141
+ }
142
+ return info;
143
+ });
144
+
145
+ return Swarm.Provider.of({
146
+ list: () => Effect.succeed([]),
147
+ read: Effect.fn(function* ({ olds, output }) {
148
+ const context = dockerContextName(olds?.context);
149
+ const info = yield* observe(context);
150
+ if (!info) return undefined;
151
+ const attrs = toSwarmAttributes(info, context);
152
+ if (output) return attrs;
153
+ // A swarm carries no labels to brand, so an already-initialized
154
+ // engine can never be proven ours — adoption is gated behind
155
+ // `--adopt` / `adopt(true)`.
156
+ return Unowned(attrs);
157
+ }),
158
+ diff: Effect.fn(function* ({ news, olds }) {
159
+ if (!isResolved(news)) return undefined;
160
+ if (dockerContextName(olds?.context) !== dockerContextName(news?.context)) {
161
+ return { action: "replace" as const, deleteFirst: true };
162
+ }
163
+ // The remaining props only apply at `swarm init`; changing them on
164
+ // a live swarm is a no-op rather than a destructive re-init.
165
+ return { action: "noop" as const };
166
+ }),
167
+ reconcile: Effect.fn(function* ({ news }) {
168
+ const context = dockerContextName(news?.context);
169
+ const existing = yield* observe(context);
170
+ if (existing) return toSwarmAttributes(existing, context);
171
+
172
+ yield* docker.swarm
173
+ .init({
174
+ context,
175
+ "advertise-addr": news?.advertiseAddr,
176
+ "listen-addr": news?.listenAddr,
177
+ "default-addr-pool": news?.defaultAddrPool,
178
+ "default-addr-pool-mask-length": news?.subnetSize,
179
+ })
180
+ .pipe(
181
+ // A concurrent init (or an interrupted prior reconcile) already
182
+ // made this node a swarm member — converge on the observation.
183
+ Effect.catchIf(
184
+ (error) => /already part of a swarm/i.test(String(error)),
185
+ () => Effect.void,
186
+ ),
187
+ );
188
+
189
+ const info = yield* observe(context);
190
+ if (!info) {
191
+ return yield* Effect.die(
192
+ new Error(
193
+ "docker swarm init succeeded but the engine did not become an active swarm manager",
194
+ ),
195
+ );
196
+ }
197
+ return toSwarmAttributes(info, context);
198
+ }),
199
+ delete: Effect.fn(({ output }) =>
200
+ docker.swarm.leave(true, output.context).pipe(
201
+ Effect.catchIf(
202
+ (error) => /not part of a swarm/i.test(String(error)),
203
+ () => Effect.void,
204
+ ),
205
+ Effect.asVoid,
206
+ ),
207
+ ),
208
+ });
209
+ }),
210
+ );
211
+
212
+ const toSwarmAttributes = (
213
+ info: Docker.SwarmInfo,
214
+ context: string | undefined,
215
+ ): Swarm["Attributes"] => ({
216
+ id: info.Cluster!.ID,
217
+ nodeId: info.NodeID,
218
+ context,
219
+ managers: info.Managers ?? 1,
220
+ nodes: info.Nodes ?? 1,
221
+ });
package/src/Volume.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { Unowned } from "alchemy/AdoptPolicy";
2
+ import { isResolved } from "alchemy/Diff";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import { createInternalTags, hasAlchemyTags, stripInternalTags } from "alchemy/Tags";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Equal from "effect/Equal";
8
+ import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
9
+ import type { Providers } from "./Providers.ts";
10
+
11
+ export interface VolumeLabel {
12
+ /** Label name. */
13
+ name: string;
14
+ /** Label value. */
15
+ value: string;
16
+ }
17
+
18
+ export interface VolumeProps {
19
+ /**
20
+ * Docker volume name.
21
+ *
22
+ * @default Generated from stack, stage, logical id, and instance id.
23
+ */
24
+ name?: string;
25
+ /** Volume driver. @default "local" */
26
+ driver?: string;
27
+ /** Driver-specific options. */
28
+ driverOpts?: Record<string, string>;
29
+ /** Custom metadata labels. */
30
+ labels?: Record<string, string>;
31
+ /** Docker context name or context resource. */
32
+ context?: Docker.ContextRef;
33
+ }
34
+
35
+ export interface Volume extends Resource<
36
+ "Docker.Volume",
37
+ VolumeProps,
38
+ {
39
+ /** Docker volume name. */
40
+ id: string;
41
+ /** Docker volume name. */
42
+ name: string;
43
+ /** Volume driver. */
44
+ driver: string;
45
+ /** Driver-specific options reported by Docker. */
46
+ driverOpts: Record<string, string>;
47
+ /** Labels reported by Docker. */
48
+ labels: Record<string, string>;
49
+ /** Host mountpoint path. */
50
+ mountpoint?: string;
51
+ /** Creation timestamp in milliseconds since epoch. */
52
+ createdAt: number;
53
+ },
54
+ never,
55
+ Providers
56
+ > {}
57
+
58
+ /**
59
+ * A Docker volume managed through the active Docker context.
60
+ *
61
+ * Pre-existing same-name volumes are treated as foreign until the engine is
62
+ * allowed to adopt them with `--adopt` or `adopt(true)`.
63
+ *
64
+ *
65
+ * ### Creating Volumes
66
+ * **Example:** Basic volume
67
+ * ```typescript
68
+ * const data = yield* Docker.Volume("data", {
69
+ * name: "app-data",
70
+ * });
71
+ * ```
72
+ *
73
+ * **Example:** PostgreSQL data volume
74
+ * ```typescript
75
+ * const data = yield* Docker.Volume("postgres-data");
76
+ * ```
77
+ *
78
+ * **Example:** Driver options and labels
79
+ * ```typescript
80
+ * const data = yield* Docker.Volume("db-data", {
81
+ * driver: "local",
82
+ * driverOpts: {
83
+ * type: "nfs",
84
+ * o: "addr=10.0.0.1,rw",
85
+ * device: ":/path/to/dir",
86
+ * },
87
+ * labels: {
88
+ * "com.example.usage": "database",
89
+ * },
90
+ * });
91
+ * ```
92
+ *
93
+ * ### Docker Context
94
+ * **Example:** Create a volume in a named Docker context
95
+ * ```typescript
96
+ * const data = yield* Docker.Volume("data", {
97
+ * name: "app-data",
98
+ * context: "remote-build",
99
+ * });
100
+ * ```
101
+ *
102
+ * @resource
103
+ * @product Volume
104
+ */
105
+ export const Volume = Resource<Volume>("Docker.Volume");
106
+
107
+ export const VolumeProvider = () =>
108
+ Provider.effect(
109
+ Volume,
110
+ Effect.gen(function* () {
111
+ const docker = yield* Docker;
112
+
113
+ return Volume.Provider.of({
114
+ list: () => Effect.succeed([]),
115
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
116
+ const context = dockerContextName(olds?.context);
117
+ const name = yield* dockerPhysicalName(id, olds, instanceId);
118
+ const info = yield* docker.volume
119
+ .inspect(name, context)
120
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
121
+ if (!info) return undefined;
122
+ const attrs = toVolumeAttributes(info);
123
+ if (output) return attrs;
124
+ // Without prior state, only adopt a volume that carries our branding;
125
+ // anything else is foreign and gated behind `--adopt`.
126
+ const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined);
127
+ return owned ? attrs : Unowned(attrs);
128
+ }),
129
+ diff: Effect.fn(function* ({ id, instanceId, output, news, olds }) {
130
+ if (!isResolved(news)) return undefined;
131
+ if (dockerContextName(olds?.context) !== dockerContextName(news?.context)) {
132
+ return { action: "replace" as const, deleteFirst: true };
133
+ }
134
+ const args = yield* makeVolumeArgs(id, news, instanceId);
135
+ // Auto-generated names are engine-owned: the deployed name stays
136
+ // authoritative even if the generator would name this id differently
137
+ // today. Only an explicit user-provided name can force a replace.
138
+ const desiredName = news?.name ?? output?.name ?? args.name;
139
+ if (
140
+ output?.name !== desiredName ||
141
+ output?.driver !== args.driver ||
142
+ !Equal.equals(output?.driverOpts ?? {}, args.opt ?? {}) ||
143
+ // Compare only user labels; internal `alchemy::*` branding lives on
144
+ // the observed volume but must not drive replacement.
145
+ !Equal.equals(stripInternalTags(output?.labels), args.label ?? {})
146
+ ) {
147
+ return { action: "replace" as const, deleteFirst: true };
148
+ }
149
+ }),
150
+ reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
151
+ const context = dockerContextName(news?.context);
152
+ const args = yield* makeVolumeArgs(id, news, instanceId);
153
+ // Prefer the deployed name: regenerating would target a different
154
+ // volume if the generator's output for this id ever drifts.
155
+ const name = news?.name ?? output?.name ?? args.name;
156
+ const internalTags = yield* createInternalTags(id);
157
+ const result = yield* docker.volume.create({
158
+ ...args,
159
+ name,
160
+ label: { ...internalTags, ...args.label },
161
+ context,
162
+ });
163
+ return toVolumeAttributes(yield* docker.volume.inspect(result.stdout, context));
164
+ }),
165
+ delete: Effect.fn(({ olds, output }) =>
166
+ docker.volume
167
+ .remove(output.name, dockerContextName(olds?.context))
168
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void)),
169
+ ),
170
+ });
171
+ }),
172
+ );
173
+
174
+ // Every prop is optional, so `Docker.Volume("data")` arrives with no props.
175
+ const makeVolumeArgs = (id: string, props: VolumeProps | undefined, instanceId: string) =>
176
+ dockerPhysicalName(id, props, instanceId).pipe(
177
+ Effect.map((name): Parameters<Docker["Service"]["volume"]["create"]>[0] => ({
178
+ name,
179
+ driver: props?.driver ?? "local",
180
+ opt: props?.driverOpts,
181
+ label: props?.labels,
182
+ })),
183
+ );
184
+
185
+ export const toVolumeAttributes = (info: Docker.Volume): Volume["Attributes"] => ({
186
+ id: info.Name,
187
+ name: info.Name,
188
+ driver: info.Driver,
189
+ driverOpts: info.Options ?? {},
190
+ labels: info.Labels ?? {},
191
+ mountpoint: info.Mountpoint,
192
+ createdAt: Date.parse(info.CreatedAt) || Date.now(),
193
+ });
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./Container.ts";
2
+ export * from "./Context.ts";
3
+ export * from "./Docker.ts";
4
+ export * as Dockerfile from "./Dockerfile.ts";
5
+ export * from "./Image.ts";
6
+ export * from "./Network.ts";
7
+ export * from "./Providers.ts";
8
+ export type { ImageRegistry } from "./Registry.ts";
9
+ export {
10
+ DockerRegistryBlobUnknown,
11
+ DockerRegistryUnavailable,
12
+ type DockerImagePublicationError,
13
+ } from "./RegistryError.ts";
14
+ export * from "./RemoteImage.ts";
15
+ export * from "./Service.ts";
16
+ export * from "./Swarm.ts";
17
+ export * from "./Volume.ts";