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

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 +46 -4
  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 +56 -0
  8. package/lib/BuildHash.d.ts.map +1 -0
  9. package/lib/BuildHash.js +172 -0
  10. package/lib/BuildHash.js.map +1 -0
  11. package/lib/Container.d.ts +450 -0
  12. package/lib/Container.d.ts.map +1 -0
  13. package/lib/Container.js +745 -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 +527 -0
  20. package/lib/Docker.d.ts.map +1 -0
  21. package/lib/Docker.js +437 -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 +179 -0
  32. package/lib/Image.d.ts.map +1 -0
  33. package/lib/Image.js +210 -0
  34. package/lib/Image.js.map +1 -0
  35. package/lib/ImageSource.d.ts +288 -0
  36. package/lib/ImageSource.d.ts.map +1 -0
  37. package/lib/ImageSource.js +498 -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 +499 -0
  60. package/lib/Service.d.ts.map +1 -0
  61. package/lib/Service.js +730 -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 +58 -3
  80. package/src/Bootstrap.ts +51 -0
  81. package/src/BuildHash.ts +265 -0
  82. package/src/Container.ts +1118 -0
  83. package/src/Context.ts +215 -0
  84. package/src/Docker.ts +1173 -0
  85. package/src/Dockerfile.ts +81 -0
  86. package/src/HealthcheckCommand.ts +35 -0
  87. package/src/Image.ts +322 -0
  88. package/src/ImageSource.ts +842 -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 +1264 -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,81 @@
1
+ /**
2
+ * Inline Dockerfile content for container platforms.
3
+ *
4
+ * Container platform props accept `dockerfile: string | { content: Input<string> }`:
5
+ * a plain string is always a **path** (relative to `context`); an object with a
6
+ * `content` key is **inline Dockerfile content**. `Dockerfile.inline` is the
7
+ * ergonomic way to produce the latter — a tagged template whose interpolations
8
+ * ride the normal {@link Output} machinery, so referencing another resource's
9
+ * attributes (e.g. an {@link ECR.Image}'s `imageUri`) creates a real dependency
10
+ * edge and re-resolves on change.
11
+ *
12
+ * The returned value is a plain JSON-serializable object (no symbols or
13
+ * brands) so it round-trips through persisted state unchanged; the structural
14
+ * `content` key is the discriminant.
15
+ *
16
+ * @example Base plus system packages, still bundling your Effect program
17
+ * ```typescript
18
+ * import * as Dockerfile from "@alchemy.run/docker/Dockerfile";
19
+ *
20
+ * const worker = yield* AWS.ECS.Task(
21
+ * "Transcoder",
22
+ * {
23
+ * cluster,
24
+ * main: import.meta.url,
25
+ * dockerfile: Dockerfile.inline`
26
+ * FROM oven/bun:1
27
+ * RUN apt-get update && apt-get install -y ffmpeg
28
+ * `,
29
+ * },
30
+ * impl,
31
+ * );
32
+ * ```
33
+ *
34
+ * @example Deriving from another resource's image (dependency edge for free)
35
+ * ```typescript
36
+ * const base = yield* AWS.ECR.Image("MlBase", { context: "./ml-base" });
37
+ *
38
+ * const trainer = yield* AWS.ECS.Task(
39
+ * "Trainer",
40
+ * {
41
+ * cluster,
42
+ * main: import.meta.url,
43
+ * dockerfile: Dockerfile.inline`
44
+ * FROM ${base.imageUri}
45
+ * RUN pip install -r /opt/requirements.txt
46
+ * `,
47
+ * },
48
+ * impl,
49
+ * );
50
+ * ```
51
+ */
52
+ import type { Input } from "alchemy/Input";
53
+ import * as Output from "alchemy/Output";
54
+
55
+ /**
56
+ * Inline Dockerfile content. A plain, state-serializable object; the
57
+ * structural `content` key distinguishes it from a path string wherever
58
+ * `dockerfile` props accept `string | InlineDockerfile`.
59
+ *
60
+ * Never interpolate secrets: anything in Dockerfile content is baked into
61
+ * image layers. `Input<string>` excludes `Redacted` values at the type level.
62
+ */
63
+ export interface InlineDockerfile {
64
+ readonly content: Input<string>;
65
+ }
66
+
67
+ /** Structural guard for {@link InlineDockerfile} vs a path string. */
68
+ export const isInlineDockerfile = (value: unknown): value is InlineDockerfile =>
69
+ typeof value === "object" && value !== null && "content" in value;
70
+
71
+ /**
72
+ * Tagged template producing {@link InlineDockerfile}. Interpolations may be
73
+ * plain strings or `Output<string>`s (resolved at deploy time via
74
+ * `Output.interpolate`); with no interpolations the content is a plain string.
75
+ */
76
+ export const inline = (
77
+ template: TemplateStringsArray,
78
+ ...args: Array<Input<string>>
79
+ ): InlineDockerfile => ({
80
+ content: args.length === 0 ? template.raw.join("") : Output.interpolate(template, ...args),
81
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Renders a healthcheck `cmd` as the single string Docker's `--health-cmd`
3
+ * flag takes. The flag always runs its value through the container's shell, so
4
+ * the Docker API's exec form has to be flattened:
5
+ *
6
+ * - `"cmd"`: used as is.
7
+ * - `["CMD-SHELL", "cmd"]`: the shell command, without the marker.
8
+ * - `["CMD", "pg_isready", "-U", "app"]`: the arguments, each quoted so it
9
+ * reaches the program as one argument.
10
+ * - `["NONE"]`: no command (the caller disables the healthcheck).
11
+ * - any other array: joined with spaces and run in the shell, as before, so
12
+ * `["curl -f localhost || exit 1"]` keeps working.
13
+ */
14
+ export const healthcheckCommand = (cmd: string[] | string): string | undefined => {
15
+ if (typeof cmd === "string") return cmd;
16
+ const [head, ...rest] = cmd;
17
+ switch (head) {
18
+ case "NONE":
19
+ return undefined;
20
+ case "CMD-SHELL":
21
+ return rest.join(" ");
22
+ case "CMD":
23
+ return rest.map(shellQuote).join(" ");
24
+ default:
25
+ return cmd.join(" ");
26
+ }
27
+ };
28
+
29
+ /** True when `cmd` is Docker's `["NONE"]` form, which disables the healthcheck. */
30
+ export const isHealthcheckDisabled = (cmd: string[] | string): boolean =>
31
+ Array.isArray(cmd) && cmd[0] === "NONE";
32
+
33
+ /** Single-quotes an argument unless it is made only of shell-safe characters. */
34
+ const shellQuote = (arg: string): string =>
35
+ /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
package/src/Image.ts ADDED
@@ -0,0 +1,322 @@
1
+ import * as Artifacts from "alchemy/Artifacts";
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 * as FileSystem from "effect/FileSystem";
7
+ import * as Path from "effect/Path";
8
+ import type * as Redacted from "effect/Redacted";
9
+ import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
10
+ import type { Providers } from "./Providers.ts";
11
+ import {
12
+ type ImageRegistry,
13
+ parseCreatedAt,
14
+ publishedRepoDigest,
15
+ repositoryFromImageRef,
16
+ withRegistryHost,
17
+ } from "./Registry.ts";
18
+
19
+ export interface DockerBuildOptions {
20
+ /**
21
+ * Build context directory.
22
+ *
23
+ * @default Current working directory.
24
+ */
25
+ context?: string;
26
+ /**
27
+ * Dockerfile path, relative to the context unless absolute.
28
+ *
29
+ * @default "Dockerfile"
30
+ */
31
+ dockerfile?: string;
32
+ /** Target platform, e.g. `"linux/amd64"`. */
33
+ platform?: string;
34
+ /**
35
+ * Docker build arguments (`--build-arg`). Never pass secrets here: build
36
+ * args are visible in the image history. Use {@link secrets} instead.
37
+ */
38
+ args?: Record<string, string>;
39
+ /**
40
+ * BuildKit secrets, keyed by id. Mount one in a `RUN` step with
41
+ * `--mount=type=secret,id=<id>` and read it from `/run/secrets/<id>`, or
42
+ * add `env=<NAME>` to expose it as an environment variable for that step.
43
+ * A secret never enters the image, its build history, or `docker`'s argv.
44
+ */
45
+ secrets?: Record<string, Redacted.Redacted<string>>;
46
+ /** Multi-stage build target. */
47
+ target?: string;
48
+ /** Cache sources passed as `--cache-from`. */
49
+ cacheFrom?: string[];
50
+ /** Cache destinations passed as `--cache-to`. */
51
+ cacheTo?: string[];
52
+ /** Additional Docker build options. */
53
+ options?: string[];
54
+ }
55
+
56
+ export interface ImageProps {
57
+ /**
58
+ * Repository/name for the built image.
59
+ *
60
+ * @default Generated from stack, stage, logical id, and instance id.
61
+ */
62
+ name?: string;
63
+ /** Image tag. @default "latest" */
64
+ tag?: string;
65
+ /**
66
+ * Registry credentials. The image is pushed to this registry (unless
67
+ * `skipPush`), and the build itself authenticates with them, so private
68
+ * base images and `type=registry` caches hosted there resolve without a
69
+ * host `docker login`. Credentials already in `DOCKER_AUTH_CONFIG` for
70
+ * other registries are kept. Build-time authentication needs Buildx 0.26+
71
+ * (Docker Desktop 4.44+) or the legacy builder; older Buildx plugins ignore
72
+ * it, so a private base image there still needs `docker login`.
73
+ */
74
+ registry?: ImageRegistry;
75
+ /** Skip registry push even when `registry` is set. @default false */
76
+ skipPush?: boolean;
77
+ /** Docker context name or context resource. */
78
+ context?: Docker.ContextRef;
79
+ /** Docker build configuration. */
80
+ build: DockerBuildOptions;
81
+ }
82
+
83
+ export interface Image extends Resource<
84
+ "Docker.Image",
85
+ ImageProps,
86
+ {
87
+ /** Image repository/name without tag. */
88
+ name: string;
89
+ /** Final image reference. Includes registry host when pushed there. */
90
+ imageRef: string;
91
+ /** Local image id after build/tag. */
92
+ imageId: string;
93
+ /** Registry digest after push when available. */
94
+ repoDigest?: string;
95
+ /** Tag used for the local image. */
96
+ tag: string;
97
+ /** Build timestamp in milliseconds since epoch. */
98
+ builtAt: number;
99
+ },
100
+ never,
101
+ Providers
102
+ > {}
103
+
104
+ /**
105
+ * Builds, tags, and optionally pushes Docker images through the active Docker
106
+ * context.
107
+ *
108
+ * This resource uses the Docker CLI and whatever daemon or remote context the
109
+ * CLI is configured to target. It is separate from `Cloudflare.Container`;
110
+ * registry image references are the boundary between Docker-managed images and
111
+ * cloud container platforms.
112
+ *
113
+ * `Image` always builds from a Dockerfile. To pull (and optionally re-tag and
114
+ * push) an existing registry image, use `Docker.RemoteImage`.
115
+ *
116
+ *
117
+ * ### Building Images
118
+ * **Example:** Build from a Dockerfile
119
+ * ```typescript
120
+ * const image = yield* Docker.Image("app", {
121
+ * name: "my-app",
122
+ * tag: "latest",
123
+ * build: {
124
+ * context: "./app",
125
+ * dockerfile: "Dockerfile",
126
+ * args: { NODE_ENV: "production" },
127
+ * },
128
+ * });
129
+ * ```
130
+ *
131
+ * ### Build Secrets
132
+ * **Example:** Install from a private npm registry
133
+ * ```typescript
134
+ * // Dockerfile:
135
+ * // RUN --mount=type=secret,id=NPM_TOKEN,env=NPM_TOKEN npm ci
136
+ * const image = yield* Docker.Image("app", {
137
+ * name: "my-app",
138
+ * build: {
139
+ * context: "./app",
140
+ * secrets: { NPM_TOKEN: Config.Redacted("NPM_TOKEN") },
141
+ * },
142
+ * });
143
+ * ```
144
+ *
145
+ * The secret is mounted only into the `RUN` step that asks for it; it never
146
+ * lands in an image layer or the build history.
147
+ *
148
+ * ### Registry Push
149
+ * **Example:** Push with Redacted credentials
150
+ * ```typescript
151
+ * const image = yield* Docker.Image("app", {
152
+ * name: "my-app",
153
+ * build: { context: "./app" },
154
+ * registry: {
155
+ * server: "ghcr.io",
156
+ * username: "octocat",
157
+ * password: Config.Redacted("GITHUB_TOKEN"),
158
+ * },
159
+ * });
160
+ * ```
161
+ *
162
+ * ### Private Base Images and Registry Caches
163
+ * **Example:** Build FROM a private base image with a registry cache
164
+ * ```typescript
165
+ * // Dockerfile: FROM registry.example.com/base:v1
166
+ * const image = yield* Docker.Image("app", {
167
+ * name: "registry.example.com/app",
168
+ * build: {
169
+ * context: "./app",
170
+ * cacheFrom: ["type=registry,ref=registry.example.com/app:buildcache"],
171
+ * cacheTo: ["type=registry,ref=registry.example.com/app:buildcache,mode=max"],
172
+ * },
173
+ * // Authenticates the base-image pull and cache import/export, then the push.
174
+ * registry: {
175
+ * server: "registry.example.com",
176
+ * username: "deploy",
177
+ * password: Config.Redacted("REGISTRY_PASSWORD"),
178
+ * },
179
+ * });
180
+ * ```
181
+ *
182
+ * ### Docker Context
183
+ * **Example:** Build in a named Docker context
184
+ * ```typescript
185
+ * const image = yield* Docker.Image("app", {
186
+ * name: "my-app",
187
+ * context: "remote-build",
188
+ * build: { context: "./app" },
189
+ * });
190
+ * ```
191
+ *
192
+ * @resource
193
+ * @product Image
194
+ */
195
+ export const Image = Resource<Image>("Docker.Image");
196
+
197
+ export const ImageProvider = () =>
198
+ Provider.effect(
199
+ Image,
200
+ Effect.gen(function* () {
201
+ const fs = yield* FileSystem.FileSystem;
202
+ const path = yield* Path.Path;
203
+ const docker = yield* Docker;
204
+
205
+ const buildAndInspectImage = Effect.fn(function* (
206
+ id: string,
207
+ props: ImageProps,
208
+ instanceId: string,
209
+ ) {
210
+ const name = yield* dockerPhysicalName(id, props, instanceId);
211
+ const engineContext = dockerContextName(props.context);
212
+ const tag = props.tag ?? "latest";
213
+ const ref = `${name}:${tag}`;
214
+
215
+ const paths = yield* resolveBuildPaths(props.build);
216
+ yield* docker.image.build({
217
+ tag: ref,
218
+ context: paths.context,
219
+ file: paths.dockerfile,
220
+ platform: props.build.platform,
221
+ target: props.build.target,
222
+ "build-arg": props.build.args,
223
+ secrets: props.build.secrets,
224
+ "cache-from": props.build.cacheFrom,
225
+ "cache-to": props.build.cacheTo,
226
+ args: props.build.options,
227
+ engineContext,
228
+ // Base images and registry caches may live in the same registry;
229
+ // the push itself stays in `reconcile`.
230
+ credentials: props.registry,
231
+ });
232
+
233
+ // Read the freshly built image's id and creation time straight from
234
+ // Docker rather than synthesizing a wall-clock timestamp.
235
+ return {
236
+ name,
237
+ tag,
238
+ image: yield* docker.image.inspect(ref, engineContext),
239
+ ref,
240
+ };
241
+ }, Artifacts.cached("build"));
242
+
243
+ const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) {
244
+ const cwd = yield* Effect.sync(() => process.cwd());
245
+ const context = path.resolve(build.context ?? cwd);
246
+ const dockerfile = build.dockerfile
247
+ ? path.isAbsolute(build.dockerfile)
248
+ ? build.dockerfile
249
+ : path.resolve(context, build.dockerfile)
250
+ : path.resolve(context, "Dockerfile");
251
+ if (!(yield* fs.exists(context))) {
252
+ return yield* Effect.die(`Docker build context does not exist: ${context}`);
253
+ }
254
+ if (!(yield* fs.exists(dockerfile))) {
255
+ return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`);
256
+ }
257
+ return { context, dockerfile };
258
+ });
259
+
260
+ return Image.Provider.of({
261
+ list: () => Effect.succeed([]),
262
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
263
+ const context = dockerContextName(olds.context);
264
+ const ref =
265
+ output?.imageRef ??
266
+ (yield* dockerPhysicalName(id, olds, instanceId).pipe(
267
+ Effect.map((name) => `${name}:${olds.tag ?? "latest"}`),
268
+ ));
269
+ const image = yield* docker.image
270
+ .inspect(ref, context)
271
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
272
+ if (!image) return undefined;
273
+ return {
274
+ name: output?.name ?? repositoryFromImageRef(ref),
275
+ imageRef: ref,
276
+ imageId: image.Id,
277
+ repoDigest: output?.repoDigest,
278
+ tag: output?.tag ?? olds.tag ?? "latest",
279
+ builtAt: output?.builtAt ?? parseCreatedAt(image.Created),
280
+ };
281
+ }),
282
+ diff: Effect.fn(function* ({ id, instanceId, news, output, olds }) {
283
+ if (!isResolved(news) || !output) return undefined;
284
+ if (dockerContextName(olds.context) !== dockerContextName(news.context)) {
285
+ return { action: "update" };
286
+ }
287
+ const { image } = yield* buildAndInspectImage(id, news, instanceId);
288
+ if (output?.imageId !== image.Id) {
289
+ return { action: "update" };
290
+ }
291
+ }),
292
+ reconcile: Effect.fn(function* ({ id, instanceId, news, session }) {
293
+ const context = dockerContextName(news.context);
294
+ const { name, tag, image, ref } = yield* buildAndInspectImage(id, news, instanceId);
295
+
296
+ let repoDigest: string | undefined;
297
+ let targetImageRef: string = ref;
298
+ if (news.registry && !news.skipPush) {
299
+ yield* session.note(`Pushing image to registry "${news.registry.server}"`);
300
+ targetImageRef = withRegistryHost(ref, news.registry);
301
+ const pushed = yield* docker.image.push(ref, news.registry, undefined, context);
302
+ const published = yield* docker.image.inspect(ref, context);
303
+ repoDigest = publishedRepoDigest(ref, pushed, published.RepoDigests, targetImageRef);
304
+ }
305
+
306
+ return {
307
+ name,
308
+ imageRef: targetImageRef,
309
+ imageId: image.Id,
310
+ repoDigest,
311
+ tag,
312
+ builtAt: parseCreatedAt(image.Created),
313
+ };
314
+ }),
315
+ delete: Effect.fn(({ olds, output }) =>
316
+ docker.image
317
+ .remove(output.imageRef, undefined, dockerContextName(olds.context))
318
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void)),
319
+ ),
320
+ });
321
+ }),
322
+ );