@alchemy.run/docker 0.0.0 → 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 +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 +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 +58 -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,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,293 @@
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 { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
9
+ import type { Providers } from "./Providers.ts";
10
+ import {
11
+ type ImageRegistry,
12
+ parseCreatedAt,
13
+ publishedRepoDigest,
14
+ repositoryFromImageRef,
15
+ withRegistryHost,
16
+ } from "./Registry.ts";
17
+
18
+ export interface DockerBuildOptions {
19
+ /**
20
+ * Build context directory.
21
+ *
22
+ * @default Current working directory.
23
+ */
24
+ context?: string;
25
+ /**
26
+ * Dockerfile path, relative to the context unless absolute.
27
+ *
28
+ * @default "Dockerfile"
29
+ */
30
+ dockerfile?: string;
31
+ /** Target platform, e.g. `"linux/amd64"`. */
32
+ platform?: string;
33
+ /** Docker build arguments. */
34
+ args?: Record<string, string>;
35
+ /** Multi-stage build target. */
36
+ target?: string;
37
+ /** Cache sources passed as `--cache-from`. */
38
+ cacheFrom?: string[];
39
+ /** Cache destinations passed as `--cache-to`. */
40
+ cacheTo?: string[];
41
+ /** Additional Docker build options. */
42
+ options?: string[];
43
+ }
44
+
45
+ export interface ImageProps {
46
+ /**
47
+ * Repository/name for the built image.
48
+ *
49
+ * @default Generated from stack, stage, logical id, and instance id.
50
+ */
51
+ name?: string;
52
+ /** Image tag. @default "latest" */
53
+ tag?: string;
54
+ /**
55
+ * Registry credentials. The image is pushed to this registry (unless
56
+ * `skipPush`), and the build itself authenticates with them, so private
57
+ * base images and `type=registry` caches hosted there resolve without a
58
+ * host `docker login`. Credentials already in `DOCKER_AUTH_CONFIG` for
59
+ * other registries are kept. Build-time authentication needs Buildx 0.26+
60
+ * (Docker Desktop 4.44+) or the legacy builder; older Buildx plugins ignore
61
+ * it, so a private base image there still needs `docker login`.
62
+ */
63
+ registry?: ImageRegistry;
64
+ /** Skip registry push even when `registry` is set. @default false */
65
+ skipPush?: boolean;
66
+ /** Docker context name or context resource. */
67
+ context?: Docker.ContextRef;
68
+ /** Docker build configuration. */
69
+ build: DockerBuildOptions;
70
+ }
71
+
72
+ export interface Image extends Resource<
73
+ "Docker.Image",
74
+ ImageProps,
75
+ {
76
+ /** Image repository/name without tag. */
77
+ name: string;
78
+ /** Final image reference. Includes registry host when pushed there. */
79
+ imageRef: string;
80
+ /** Local image id after build/tag. */
81
+ imageId: string;
82
+ /** Registry digest after push when available. */
83
+ repoDigest?: string;
84
+ /** Tag used for the local image. */
85
+ tag: string;
86
+ /** Build timestamp in milliseconds since epoch. */
87
+ builtAt: number;
88
+ },
89
+ never,
90
+ Providers
91
+ > {}
92
+
93
+ /**
94
+ * Builds, tags, and optionally pushes Docker images through the active Docker
95
+ * context.
96
+ *
97
+ * This resource uses the Docker CLI and whatever daemon or remote context the
98
+ * CLI is configured to target. It is separate from `Cloudflare.Container`;
99
+ * registry image references are the boundary between Docker-managed images and
100
+ * cloud container platforms.
101
+ *
102
+ * `Image` always builds from a Dockerfile. To pull (and optionally re-tag and
103
+ * push) an existing registry image, use `Docker.RemoteImage`.
104
+ *
105
+ *
106
+ * ### Building Images
107
+ * **Example:** Build from a Dockerfile
108
+ * ```typescript
109
+ * const image = yield* Docker.Image("app", {
110
+ * name: "my-app",
111
+ * tag: "latest",
112
+ * build: {
113
+ * context: "./app",
114
+ * dockerfile: "Dockerfile",
115
+ * args: { NODE_ENV: "production" },
116
+ * },
117
+ * });
118
+ * ```
119
+ *
120
+ * ### Registry Push
121
+ * **Example:** Push with Redacted credentials
122
+ * ```typescript
123
+ * const image = yield* Docker.Image("app", {
124
+ * name: "my-app",
125
+ * build: { context: "./app" },
126
+ * registry: {
127
+ * server: "ghcr.io",
128
+ * username: "octocat",
129
+ * password: Config.Redacted("GITHUB_TOKEN"),
130
+ * },
131
+ * });
132
+ * ```
133
+ *
134
+ * ### Private Base Images and Registry Caches
135
+ * **Example:** Build FROM a private base image with a registry cache
136
+ * ```typescript
137
+ * // Dockerfile: FROM registry.example.com/base:v1
138
+ * const image = yield* Docker.Image("app", {
139
+ * name: "registry.example.com/app",
140
+ * build: {
141
+ * context: "./app",
142
+ * cacheFrom: ["type=registry,ref=registry.example.com/app:buildcache"],
143
+ * cacheTo: ["type=registry,ref=registry.example.com/app:buildcache,mode=max"],
144
+ * },
145
+ * // Authenticates the base-image pull and cache import/export, then the push.
146
+ * registry: {
147
+ * server: "registry.example.com",
148
+ * username: "deploy",
149
+ * password: Config.Redacted("REGISTRY_PASSWORD"),
150
+ * },
151
+ * });
152
+ * ```
153
+ *
154
+ * ### Docker Context
155
+ * **Example:** Build in a named Docker context
156
+ * ```typescript
157
+ * const image = yield* Docker.Image("app", {
158
+ * name: "my-app",
159
+ * context: "remote-build",
160
+ * build: { context: "./app" },
161
+ * });
162
+ * ```
163
+ *
164
+ * @resource
165
+ * @product Image
166
+ */
167
+ export const Image = Resource<Image>("Docker.Image");
168
+
169
+ export const ImageProvider = () =>
170
+ Provider.effect(
171
+ Image,
172
+ Effect.gen(function* () {
173
+ const fs = yield* FileSystem.FileSystem;
174
+ const path = yield* Path.Path;
175
+ const docker = yield* Docker;
176
+
177
+ const buildAndInspectImage = Effect.fn(function* (
178
+ id: string,
179
+ props: ImageProps,
180
+ instanceId: string,
181
+ ) {
182
+ const name = yield* dockerPhysicalName(id, props, instanceId);
183
+ const engineContext = dockerContextName(props.context);
184
+ const tag = props.tag ?? "latest";
185
+ const ref = `${name}:${tag}`;
186
+
187
+ const paths = yield* resolveBuildPaths(props.build);
188
+ yield* docker.image.build({
189
+ tag: ref,
190
+ context: paths.context,
191
+ file: paths.dockerfile,
192
+ platform: props.build.platform,
193
+ target: props.build.target,
194
+ "build-arg": props.build.args,
195
+ "cache-from": props.build.cacheFrom,
196
+ "cache-to": props.build.cacheTo,
197
+ args: props.build.options,
198
+ engineContext,
199
+ // Base images and registry caches may live in the same registry;
200
+ // the push itself stays in `reconcile`.
201
+ credentials: props.registry,
202
+ });
203
+
204
+ // Read the freshly built image's id and creation time straight from
205
+ // Docker rather than synthesizing a wall-clock timestamp.
206
+ return {
207
+ name,
208
+ tag,
209
+ image: yield* docker.image.inspect(ref, engineContext),
210
+ ref,
211
+ };
212
+ }, Artifacts.cached("build"));
213
+
214
+ const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) {
215
+ const cwd = yield* Effect.sync(() => process.cwd());
216
+ const context = path.resolve(build.context ?? cwd);
217
+ const dockerfile = build.dockerfile
218
+ ? path.isAbsolute(build.dockerfile)
219
+ ? build.dockerfile
220
+ : path.resolve(context, build.dockerfile)
221
+ : path.resolve(context, "Dockerfile");
222
+ if (!(yield* fs.exists(context))) {
223
+ return yield* Effect.die(`Docker build context does not exist: ${context}`);
224
+ }
225
+ if (!(yield* fs.exists(dockerfile))) {
226
+ return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`);
227
+ }
228
+ return { context, dockerfile };
229
+ });
230
+
231
+ return Image.Provider.of({
232
+ list: () => Effect.succeed([]),
233
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
234
+ const context = dockerContextName(olds.context);
235
+ const ref =
236
+ output?.imageRef ??
237
+ (yield* dockerPhysicalName(id, olds, instanceId).pipe(
238
+ Effect.map((name) => `${name}:${olds.tag ?? "latest"}`),
239
+ ));
240
+ const image = yield* docker.image
241
+ .inspect(ref, context)
242
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
243
+ if (!image) return undefined;
244
+ return {
245
+ name: output?.name ?? repositoryFromImageRef(ref),
246
+ imageRef: ref,
247
+ imageId: image.Id,
248
+ repoDigest: output?.repoDigest,
249
+ tag: output?.tag ?? olds.tag ?? "latest",
250
+ builtAt: output?.builtAt ?? parseCreatedAt(image.Created),
251
+ };
252
+ }),
253
+ diff: Effect.fn(function* ({ id, instanceId, news, output, olds }) {
254
+ if (!isResolved(news) || !output) return undefined;
255
+ if (dockerContextName(olds.context) !== dockerContextName(news.context)) {
256
+ return { action: "update" };
257
+ }
258
+ const { image } = yield* buildAndInspectImage(id, news, instanceId);
259
+ if (output?.imageId !== image.Id) {
260
+ return { action: "update" };
261
+ }
262
+ }),
263
+ reconcile: Effect.fn(function* ({ id, instanceId, news, session }) {
264
+ const context = dockerContextName(news.context);
265
+ const { name, tag, image, ref } = yield* buildAndInspectImage(id, news, instanceId);
266
+
267
+ let repoDigest: string | undefined;
268
+ let targetImageRef: string = ref;
269
+ if (news.registry && !news.skipPush) {
270
+ yield* session.note(`Pushing image to registry "${news.registry.server}"`);
271
+ targetImageRef = withRegistryHost(ref, news.registry);
272
+ const pushed = yield* docker.image.push(ref, news.registry, undefined, context);
273
+ const published = yield* docker.image.inspect(ref, context);
274
+ repoDigest = publishedRepoDigest(ref, pushed, published.RepoDigests, targetImageRef);
275
+ }
276
+
277
+ return {
278
+ name,
279
+ imageRef: targetImageRef,
280
+ imageId: image.Id,
281
+ repoDigest,
282
+ tag,
283
+ builtAt: parseCreatedAt(image.Created),
284
+ };
285
+ }),
286
+ delete: Effect.fn(({ olds, output }) =>
287
+ docker.image
288
+ .remove(output.imageRef, undefined, dockerContextName(olds.context))
289
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void)),
290
+ ),
291
+ });
292
+ }),
293
+ );