@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.
- package/LICENSE +201 -0
- package/README.md +46 -4
- package/lib/Bootstrap.d.ts +7 -0
- package/lib/Bootstrap.d.ts.map +1 -0
- package/lib/Bootstrap.js +25 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/BuildHash.d.ts +47 -0
- package/lib/BuildHash.d.ts.map +1 -0
- package/lib/BuildHash.js +165 -0
- package/lib/BuildHash.js.map +1 -0
- package/lib/Container.d.ts +352 -0
- package/lib/Container.d.ts.map +1 -0
- package/lib/Container.js +531 -0
- package/lib/Container.js.map +1 -0
- package/lib/Context.d.ts +86 -0
- package/lib/Context.d.ts.map +1 -0
- package/lib/Context.js +146 -0
- package/lib/Context.js.map +1 -0
- package/lib/Docker.d.ts +448 -0
- package/lib/Docker.d.ts.map +1 -0
- package/lib/Docker.js +416 -0
- package/lib/Docker.js.map +1 -0
- package/lib/Dockerfile.d.ts +72 -0
- package/lib/Dockerfile.d.ts.map +1 -0
- package/lib/Dockerfile.js +12 -0
- package/lib/Dockerfile.js.map +1 -0
- package/lib/HealthcheckCommand.d.ts +17 -0
- package/lib/HealthcheckCommand.d.ts.map +1 -0
- package/lib/HealthcheckCommand.js +33 -0
- package/lib/HealthcheckCommand.js.map +1 -0
- package/lib/Image.d.ts +151 -0
- package/lib/Image.d.ts.map +1 -0
- package/lib/Image.js +192 -0
- package/lib/Image.js.map +1 -0
- package/lib/ImageSource.d.ts +234 -0
- package/lib/ImageSource.d.ts.map +1 -0
- package/lib/ImageSource.js +469 -0
- package/lib/ImageSource.js.map +1 -0
- package/lib/Network.d.ts +70 -0
- package/lib/Network.d.ts.map +1 -0
- package/lib/Network.js +111 -0
- package/lib/Network.js.map +1 -0
- package/lib/Providers.d.ts +21 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +26 -0
- package/lib/Providers.js.map +1 -0
- package/lib/Registry.d.ts +47 -0
- package/lib/Registry.d.ts.map +1 -0
- package/lib/Registry.js +67 -0
- package/lib/Registry.js.map +1 -0
- package/lib/RegistryError.d.ts +21 -0
- package/lib/RegistryError.d.ts.map +1 -0
- package/lib/RegistryError.js +28 -0
- package/lib/RegistryError.js.map +1 -0
- package/lib/RemoteImage.d.ts +118 -0
- package/lib/RemoteImage.d.ts.map +1 -0
- package/lib/RemoteImage.js +164 -0
- package/lib/RemoteImage.js.map +1 -0
- package/lib/Service.d.ts +478 -0
- package/lib/Service.d.ts.map +1 -0
- package/lib/Service.js +588 -0
- package/lib/Service.js.map +1 -0
- package/lib/ServiceImage.d.ts +78 -0
- package/lib/ServiceImage.d.ts.map +1 -0
- package/lib/ServiceImage.js +162 -0
- package/lib/ServiceImage.js.map +1 -0
- package/lib/Swarm.d.ts +119 -0
- package/lib/Swarm.d.ts.map +1 -0
- package/lib/Swarm.js +148 -0
- package/lib/Swarm.js.map +1 -0
- package/lib/Volume.d.ts +94 -0
- package/lib/Volume.d.ts.map +1 -0
- package/lib/Volume.js +133 -0
- package/lib/Volume.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/package.json +58 -3
- package/src/Bootstrap.ts +51 -0
- package/src/BuildHash.ts +255 -0
- package/src/Container.ts +792 -0
- package/src/Context.ts +215 -0
- package/src/Docker.ts +1079 -0
- package/src/Dockerfile.ts +81 -0
- package/src/HealthcheckCommand.ts +35 -0
- package/src/Image.ts +293 -0
- package/src/ImageSource.ts +769 -0
- package/src/Network.ts +169 -0
- package/src/Providers.ts +46 -0
- package/src/Registry.ts +91 -0
- package/src/RegistryError.ts +48 -0
- package/src/RemoteImage.ts +255 -0
- package/src/Service.ts +1080 -0
- package/src/ServiceImage.ts +252 -0
- package/src/Swarm.ts +221 -0
- package/src/Volume.ts +193 -0
- package/src/index.ts +17 -0
|
@@ -0,0 +1,769 @@
|
|
|
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 { hashDirectory } from "alchemy/Command/Memo";
|
|
5
|
+
import { sha256Object } from "alchemy/Util/sha256";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as FileSystem from "effect/FileSystem";
|
|
8
|
+
import * as Path from "effect/Path";
|
|
9
|
+
import * as Schedule from "effect/Schedule";
|
|
10
|
+
import type * as rolldown from "rolldown";
|
|
11
|
+
import { Docker, type RegistryCredentials } from "./Docker.ts";
|
|
12
|
+
import { isInlineDockerfile, type InlineDockerfile } from "./Dockerfile.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* INTERNAL — registry-agnostic container image-source machinery shared by
|
|
16
|
+
* the container platforms (`AWS.ECS.*`, `AWS.EKS.*` via ECR, and
|
|
17
|
+
* `Kubernetes.*` workloads pushing to any registry).
|
|
18
|
+
*
|
|
19
|
+
* NOT exported from any barrel. Consumers import the module path directly.
|
|
20
|
+
*
|
|
21
|
+
* A platform's props separate the ENVIRONMENT (what the container is) from
|
|
22
|
+
* the PROGRAM (`main` — "and run my bundled Effect program in it"):
|
|
23
|
+
*
|
|
24
|
+
* - environment (at most one): `image` (registry ref) | `context` +
|
|
25
|
+
* `dockerfile`-as-path (docker build) | inline `dockerfile` content
|
|
26
|
+
* (`Dockerfile.inline`) | the default bun base when `main` stands alone.
|
|
27
|
+
* - `main` present → the bundle is injected into the environment (COPY +
|
|
28
|
+
* ENTRYPOINT) and pushed as a derived image. `main` absent → the
|
|
29
|
+
* environment runs verbatim: `image` is mirrored (docker pull → tag →
|
|
30
|
+
* push, content-addressed on the ref), a Dockerfile builds as-is.
|
|
31
|
+
*
|
|
32
|
+
* Where images land is an {@link ImageRegistryTarget}: ECR supplies one that
|
|
33
|
+
* auto-creates a private repository; a plain registry (a local `registry`
|
|
34
|
+
* container, GHCR, Docker Hub, …) supplies one that pushes to
|
|
35
|
+
* `<server>/<name>`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bundle an Effect program into a generated image. Alchemy bundles `main`
|
|
40
|
+
* with rolldown and bakes it into a Dockerfile generated from the
|
|
41
|
+
* environment (`image`, `dockerfile`, or the default bun base).
|
|
42
|
+
*/
|
|
43
|
+
export interface BundledImageSource {
|
|
44
|
+
/**
|
|
45
|
+
* Module entrypoint for the bundled program. This should typically be
|
|
46
|
+
* `import.meta.url` from an inline Effect program.
|
|
47
|
+
*/
|
|
48
|
+
main: string;
|
|
49
|
+
/**
|
|
50
|
+
* Environment image: used as the generated Dockerfile's `FROM`. Any
|
|
51
|
+
* registry ref works (private non-ECR registries require docker
|
|
52
|
+
* credentials on the build machine); the image must be able to run the
|
|
53
|
+
* bun runtime. Exclusive with {@link dockerfile} / {@link context}.
|
|
54
|
+
* @default "oven/bun:1"
|
|
55
|
+
*/
|
|
56
|
+
image?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Environment Dockerfile: a string is a PATH (built in {@link context},
|
|
59
|
+
* then the bundle is layered on top in a second stage); an
|
|
60
|
+
* {@link InlineDockerfile} is inline content used as the environment
|
|
61
|
+
* preamble (built with no context — its own `COPY`s are unsupported).
|
|
62
|
+
* The resulting environment must be able to run the bun runtime.
|
|
63
|
+
* Exclusive with {@link image}.
|
|
64
|
+
*/
|
|
65
|
+
dockerfile?: string | InlineDockerfile;
|
|
66
|
+
/**
|
|
67
|
+
* Build context for a path {@link dockerfile} environment. Exclusive
|
|
68
|
+
* with {@link image} and inline dockerfiles.
|
|
69
|
+
*/
|
|
70
|
+
context?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Named export to load from `main`.
|
|
73
|
+
* @default "default"
|
|
74
|
+
*/
|
|
75
|
+
handler?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Bundler configuration for the entrypoint. Unused code is tree-shaken.
|
|
78
|
+
* `effect`, alchemy, and `@distilled.cloud` are marked pure so unused
|
|
79
|
+
* parts prune more aggressively. List extra packages with
|
|
80
|
+
* `pure.packages`, or disable with `pure: false`.
|
|
81
|
+
*/
|
|
82
|
+
build?: Bundle.BundleConfig;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the image from the user's own Dockerfile and build context — no
|
|
87
|
+
* Effect program is bundled.
|
|
88
|
+
*/
|
|
89
|
+
export interface DockerfileImageSource {
|
|
90
|
+
/**
|
|
91
|
+
* Docker build context directory. Optional when {@link dockerfile} is
|
|
92
|
+
* inline content (which builds with an empty context).
|
|
93
|
+
*/
|
|
94
|
+
context?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Path to the Dockerfile relative to the cwd (NOT the context), or
|
|
97
|
+
* {@link InlineDockerfile} content. The Dockerfile must define its own
|
|
98
|
+
* `CMD`/`ENTRYPOINT` (no program is injected).
|
|
99
|
+
* @default `${context}/Dockerfile`
|
|
100
|
+
*/
|
|
101
|
+
dockerfile?: string | InlineDockerfile;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run a pre-built registry image. Registry-backed platforms may mirror it
|
|
106
|
+
* into their own registry (ECR: docker pull → tag → push).
|
|
107
|
+
*/
|
|
108
|
+
export interface RegistryImageSource {
|
|
109
|
+
/**
|
|
110
|
+
* A pre-built image reference, e.g.
|
|
111
|
+
* `public.ecr.aws/docker/library/busybox:stable`.
|
|
112
|
+
*/
|
|
113
|
+
image: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The image-source union. Discriminated by presence: exactly one of
|
|
118
|
+
* `main`, `context`, or `image`.
|
|
119
|
+
*/
|
|
120
|
+
export type ImageSourceProps = BundledImageSource | DockerfileImageSource | RegistryImageSource;
|
|
121
|
+
|
|
122
|
+
/** Loose bag shape used to sniff which source variant a props object is. */
|
|
123
|
+
export interface ImageSourceLike {
|
|
124
|
+
main?: string;
|
|
125
|
+
handler?: string;
|
|
126
|
+
build?: BundledImageSource["build"];
|
|
127
|
+
context?: string;
|
|
128
|
+
dockerfile?: string | InlineDockerfile;
|
|
129
|
+
image?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type ImageSourceKind = "main" | "context" | "image";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Which image source a props bag declares. `main` always wins (the other
|
|
136
|
+
* fields then describe its ENVIRONMENT); without `main`, `image` is the
|
|
137
|
+
* mirrored-verbatim source and any `context`/`dockerfile` (path or inline)
|
|
138
|
+
* is an external docker build.
|
|
139
|
+
*/
|
|
140
|
+
export const imageSourceKind = (source: ImageSourceLike): ImageSourceKind | undefined =>
|
|
141
|
+
source.main !== undefined
|
|
142
|
+
? "main"
|
|
143
|
+
: source.image !== undefined
|
|
144
|
+
? "image"
|
|
145
|
+
: source.context !== undefined || source.dockerfile !== undefined
|
|
146
|
+
? "context"
|
|
147
|
+
: undefined;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate environment-source exclusivity. Dies (plan-time defect) on
|
|
151
|
+
* `image`+`dockerfile`, `image`+`context`, or inline-`dockerfile`+`context`.
|
|
152
|
+
*/
|
|
153
|
+
export const validateImageSource = (id: string, source: ImageSourceLike): Effect.Effect<void> => {
|
|
154
|
+
if (source.image !== undefined && source.dockerfile !== undefined) {
|
|
155
|
+
return Effect.die(
|
|
156
|
+
new Error(
|
|
157
|
+
`'${id}': 'image' and 'dockerfile' are both set — declare exactly one environment source (an 'image' ref, or a Dockerfile)`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (source.image !== undefined && source.context !== undefined) {
|
|
162
|
+
return Effect.die(
|
|
163
|
+
new Error(
|
|
164
|
+
`'${id}': 'image' and 'context' are both set — declare exactly one environment source`,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
source.dockerfile !== undefined &&
|
|
170
|
+
isInlineDockerfile(source.dockerfile) &&
|
|
171
|
+
source.context !== undefined
|
|
172
|
+
) {
|
|
173
|
+
return Effect.die(
|
|
174
|
+
new Error(
|
|
175
|
+
`'${id}': inline 'dockerfile' content builds with no context — use a path dockerfile with 'context', or drop 'context'`,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return Effect.void;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Where a resolved image is pushed. Each field is an Effect so a target can
|
|
184
|
+
* create resources lazily (ECR creates the repository on first use).
|
|
185
|
+
*/
|
|
186
|
+
export interface ImageRegistryTarget<R = never> {
|
|
187
|
+
/** The repository URI images are tagged into (`<repositoryUri>:<hash>`). */
|
|
188
|
+
readonly repositoryUri: Effect.Effect<string, any, R>;
|
|
189
|
+
/**
|
|
190
|
+
* Whether the content-addressed tag is already in the registry. `true`
|
|
191
|
+
* skips the build and push entirely.
|
|
192
|
+
*/
|
|
193
|
+
readonly hasTag: (tag: string) => Effect.Effect<boolean, any, R>;
|
|
194
|
+
/**
|
|
195
|
+
* Push credentials. `undefined` pushes with the build machine's own
|
|
196
|
+
* Docker configuration (`docker login`, credential helpers).
|
|
197
|
+
*/
|
|
198
|
+
readonly credentials: Effect.Effect<RegistryCredentials | undefined, any, R>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface ResolveContainerImageOptions {
|
|
202
|
+
/** Logical resource id — keys the stable build-context directory. */
|
|
203
|
+
id: string;
|
|
204
|
+
/**
|
|
205
|
+
* The props bag carrying the image source fields (`main` / `context` /
|
|
206
|
+
* `image` plus their modifiers).
|
|
207
|
+
*/
|
|
208
|
+
source: ImageSourceLike;
|
|
209
|
+
/**
|
|
210
|
+
* Target image platform.
|
|
211
|
+
* @default "linux/amd64"
|
|
212
|
+
*/
|
|
213
|
+
platform?: string;
|
|
214
|
+
/**
|
|
215
|
+
* Port the generated Dockerfile should `ENV PORT=` + `EXPOSE`
|
|
216
|
+
* (`main` source only).
|
|
217
|
+
*/
|
|
218
|
+
port?: number;
|
|
219
|
+
/**
|
|
220
|
+
* True when the resource was declared without an inline Effect impl —
|
|
221
|
+
* `main` is then bundled as-is without the virtual-entry bootstrap.
|
|
222
|
+
*/
|
|
223
|
+
isExternal?: boolean;
|
|
224
|
+
/**
|
|
225
|
+
* The virtual-entry bootstrap wrapped around `main` for Effect-native
|
|
226
|
+
* programs: receives the resolved entry import path and returns the
|
|
227
|
+
* generated entry module source.
|
|
228
|
+
*/
|
|
229
|
+
bootstrap: (importPath: string) => string;
|
|
230
|
+
/** Plan-status session used to emit build/push progress notes. */
|
|
231
|
+
session: { note: (message: string) => Effect.Effect<void> };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolve the Dockerfile path for a `context` source: always relative to the
|
|
236
|
+
* cwd (absolute paths pass through), defaulting to `${context}/Dockerfile`.
|
|
237
|
+
*/
|
|
238
|
+
const resolveContextPaths = Effect.fn(function* (source: {
|
|
239
|
+
context: string | undefined;
|
|
240
|
+
dockerfile?: string;
|
|
241
|
+
}) {
|
|
242
|
+
const fs = yield* FileSystem.FileSystem;
|
|
243
|
+
const path = yield* Path.Path;
|
|
244
|
+
const context = path.resolve(source.context ?? ".");
|
|
245
|
+
const dockerfile = source.dockerfile
|
|
246
|
+
? path.resolve(source.dockerfile)
|
|
247
|
+
: path.join(context, "Dockerfile");
|
|
248
|
+
if (!(yield* fs.exists(context))) {
|
|
249
|
+
return yield* Effect.die(new Error(`Docker build context does not exist: ${context}`));
|
|
250
|
+
}
|
|
251
|
+
if (!(yield* fs.exists(dockerfile))) {
|
|
252
|
+
return yield* Effect.die(new Error(`Dockerfile does not exist: ${dockerfile}`));
|
|
253
|
+
}
|
|
254
|
+
return { context, dockerfile };
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Content hash for the sources whose identity is computable WITHOUT running
|
|
259
|
+
* a bundler:
|
|
260
|
+
*
|
|
261
|
+
* - `context` — hash of the build-context directory + Dockerfile content +
|
|
262
|
+
* platform.
|
|
263
|
+
* - `image` — hash of the image reference + platform (re-mirror only when
|
|
264
|
+
* the ref changes).
|
|
265
|
+
* - `main` — `undefined`: the hash comes from the bundle output, so it is
|
|
266
|
+
* only known inside `resolve`.
|
|
267
|
+
*
|
|
268
|
+
* Providers use this in `diff` to surface content drift (files changed under
|
|
269
|
+
* an unchanged `context` path) as an update.
|
|
270
|
+
*/
|
|
271
|
+
export const computeStaticSourceHash = Effect.fn(function* (
|
|
272
|
+
source: ImageSourceLike,
|
|
273
|
+
platform?: string,
|
|
274
|
+
) {
|
|
275
|
+
const kind = imageSourceKind(source);
|
|
276
|
+
if (kind === "image") {
|
|
277
|
+
return (yield* sha256Object({
|
|
278
|
+
image: source.image!,
|
|
279
|
+
platform: platform ?? "linux/amd64",
|
|
280
|
+
})).slice(0, 16);
|
|
281
|
+
}
|
|
282
|
+
if (kind === "context") {
|
|
283
|
+
if (source.dockerfile !== undefined && isInlineDockerfile(source.dockerfile)) {
|
|
284
|
+
// Inline content builds with no context; an unresolved (Output)
|
|
285
|
+
// content can't be hashed at plan time — defer to reconcile.
|
|
286
|
+
if (typeof source.dockerfile.content !== "string") return undefined;
|
|
287
|
+
return (yield* sha256Object({
|
|
288
|
+
dockerfile: source.dockerfile.content,
|
|
289
|
+
platform: platform ?? "linux/amd64",
|
|
290
|
+
})).slice(0, 16);
|
|
291
|
+
}
|
|
292
|
+
const fs = yield* FileSystem.FileSystem;
|
|
293
|
+
const { context, dockerfile } = yield* resolveContextPaths({
|
|
294
|
+
context: source.context!,
|
|
295
|
+
dockerfile: source.dockerfile,
|
|
296
|
+
});
|
|
297
|
+
const contextHash = yield* hashDirectory({ cwd: context });
|
|
298
|
+
const dockerfileContent = yield* fs.readFileString(dockerfile);
|
|
299
|
+
return (yield* sha256Object({
|
|
300
|
+
contextHash,
|
|
301
|
+
dockerfile: dockerfileContent,
|
|
302
|
+
platform: platform ?? "linux/amd64",
|
|
303
|
+
})).slice(0, 16);
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Init-time constructor for the image-source resolver. Resolves the services
|
|
310
|
+
* that are only available at provider-layer construction (Docker, the
|
|
311
|
+
* `.alchemy` directory, the rolldown virtual-entry plugin) and returns
|
|
312
|
+
* `resolve` (build/mirror + push to an {@link ImageRegistryTarget}), `hash`,
|
|
313
|
+
* and `watchMain`.
|
|
314
|
+
*/
|
|
315
|
+
export const makeContainerImageSource = Effect.gen(function* () {
|
|
316
|
+
const docker = yield* Docker;
|
|
317
|
+
const { dotAlchemy } = yield* AlchemyContext;
|
|
318
|
+
const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The exact rolldown input/output options `bundleProgram` hands to
|
|
322
|
+
* `Bundle.build` — shared with {@link watchMain} so a dev watcher's
|
|
323
|
+
* `Bundle.watch` observes the identical module graph.
|
|
324
|
+
*/
|
|
325
|
+
const mainBundleOptions = (
|
|
326
|
+
source: BundledImageSource,
|
|
327
|
+
entry: string,
|
|
328
|
+
cwd: string,
|
|
329
|
+
plugins?: rolldown.RolldownPluginOption,
|
|
330
|
+
): {
|
|
331
|
+
inputOptions: rolldown.InputOptions;
|
|
332
|
+
outputOptions: rolldown.OutputOptions;
|
|
333
|
+
} => ({
|
|
334
|
+
inputOptions: {
|
|
335
|
+
...source.build?.input,
|
|
336
|
+
input: entry,
|
|
337
|
+
cwd,
|
|
338
|
+
platform: "node",
|
|
339
|
+
// The container runs on `bun`; keep `bun`/`bun:*` external (the
|
|
340
|
+
// runtime provides them) and resolve the `bun` export condition
|
|
341
|
+
// so `@effect/platform-bun` picks its Bun implementations.
|
|
342
|
+
external: [
|
|
343
|
+
"bun",
|
|
344
|
+
"bun:*",
|
|
345
|
+
...((source.build?.input?.external as string[] | undefined) ?? []),
|
|
346
|
+
],
|
|
347
|
+
resolve: {
|
|
348
|
+
conditionNames: [...Bundle.BUN_CONDITION_NAMES],
|
|
349
|
+
...source.build?.input?.resolve,
|
|
350
|
+
},
|
|
351
|
+
plugins: [source.build?.input?.plugins, plugins],
|
|
352
|
+
},
|
|
353
|
+
outputOptions: {
|
|
354
|
+
...source.build?.output,
|
|
355
|
+
format: "esm",
|
|
356
|
+
sourcemap: source.build?.output?.sourcemap ?? false,
|
|
357
|
+
minify: source.build?.output?.minify ?? false,
|
|
358
|
+
entryFileNames: "index.mjs",
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/** Bundle the Effect program behind a `main` source. */
|
|
363
|
+
const bundleProgram = Effect.fn(function* (options: {
|
|
364
|
+
source: BundledImageSource;
|
|
365
|
+
isExternal?: boolean;
|
|
366
|
+
bootstrap: (importPath: string) => string;
|
|
367
|
+
}) {
|
|
368
|
+
const { source } = options;
|
|
369
|
+
const realMain = yield* resolveMainPath(source.main);
|
|
370
|
+
const cwd = yield* findCwdForBundle(realMain);
|
|
371
|
+
|
|
372
|
+
const buildBundle = Effect.fn(function* (
|
|
373
|
+
entry: string,
|
|
374
|
+
plugins?: rolldown.RolldownPluginOption,
|
|
375
|
+
) {
|
|
376
|
+
const opts = mainBundleOptions(source, entry, cwd, plugins);
|
|
377
|
+
return yield* Bundle.build(opts.inputOptions, opts.outputOptions, source.build);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const bundleOutput = options.isExternal
|
|
381
|
+
? yield* buildBundle(realMain)
|
|
382
|
+
: yield* buildBundle(realMain, virtualEntryPlugin(options.bootstrap));
|
|
383
|
+
|
|
384
|
+
// Return every emitted file (entry + shared chunks). Dynamic imports in
|
|
385
|
+
// the Bun HTTP server / AWS SDK split into chunks; dropping any of them
|
|
386
|
+
// crashes the container with `Cannot find module './chunk-XXX.js'`.
|
|
387
|
+
const files = bundleOutput.files.map((file) => ({
|
|
388
|
+
path: file.path,
|
|
389
|
+
content:
|
|
390
|
+
typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content,
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
return { files, hash: bundleOutput.hash };
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Bundle a `main` source and compute its content-addressed code hash.
|
|
398
|
+
*
|
|
399
|
+
* The hash covers the FULL image identity: the bundle output (which
|
|
400
|
+
* includes the generated bootstrap entry, so bootstrap-template changes
|
|
401
|
+
* invalidate it), the generated Dockerfile, and the target platform.
|
|
402
|
+
* `resolve` and `hash` share this so the plan-time diff hash and the
|
|
403
|
+
* pushed image tag always agree.
|
|
404
|
+
*/
|
|
405
|
+
const computeMainCodeHash = Effect.fn(function* (options: {
|
|
406
|
+
source: BundledImageSource;
|
|
407
|
+
isExternal?: boolean;
|
|
408
|
+
bootstrap: (importPath: string) => string;
|
|
409
|
+
port?: number;
|
|
410
|
+
platform: string;
|
|
411
|
+
}) {
|
|
412
|
+
const bundled = yield* bundleProgram({
|
|
413
|
+
source: options.source,
|
|
414
|
+
isExternal: options.isExternal,
|
|
415
|
+
bootstrap: options.bootstrap,
|
|
416
|
+
});
|
|
417
|
+
// A path-`dockerfile` environment is built as a separate local stage in
|
|
418
|
+
// `resolve`; hash its identity (context files + Dockerfile content)
|
|
419
|
+
// explicitly since the generated Dockerfile only references the local
|
|
420
|
+
// env tag. Inline environments flow through the generated Dockerfile
|
|
421
|
+
// text itself; `image` environments through its FROM line.
|
|
422
|
+
const df = options.source.dockerfile;
|
|
423
|
+
const isPathEnv = df !== undefined && !isInlineDockerfile(df);
|
|
424
|
+
let envIdentity: Record<string, string> = {};
|
|
425
|
+
if (isPathEnv) {
|
|
426
|
+
const fs = yield* FileSystem.FileSystem;
|
|
427
|
+
const { context, dockerfile } = yield* resolveContextPaths({
|
|
428
|
+
context: options.source.context!,
|
|
429
|
+
dockerfile: df,
|
|
430
|
+
});
|
|
431
|
+
envIdentity = {
|
|
432
|
+
envContextHash: yield* hashDirectory({ cwd: context }),
|
|
433
|
+
envDockerfile: yield* fs.readFileString(dockerfile),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const dockerfile = generateDockerfile(
|
|
437
|
+
options.source,
|
|
438
|
+
options.port,
|
|
439
|
+
isPathEnv ? "<env>" : undefined,
|
|
440
|
+
);
|
|
441
|
+
const codeHash = (yield* sha256Object({
|
|
442
|
+
bundleHash: bundled.hash,
|
|
443
|
+
dockerfile,
|
|
444
|
+
platform: options.platform,
|
|
445
|
+
...envIdentity,
|
|
446
|
+
})).slice(0, 16);
|
|
447
|
+
return { bundled, dockerfile, codeHash };
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Generated Dockerfile for a bundled `main` program. The environment
|
|
452
|
+
* preamble is, in order of precedence: `envFrom` (a locally-built
|
|
453
|
+
* environment tag from a path-`dockerfile` two-stage build), inline
|
|
454
|
+
* `dockerfile` content (already resolved), the `image` ref, or the
|
|
455
|
+
* default bun base (`oven/bun` is Docker-Hub only — there is no
|
|
456
|
+
* `docker/library/bun` and no `public.ecr.aws/oven/bun`).
|
|
457
|
+
*/
|
|
458
|
+
const generateDockerfile = (source: BundledImageSource, port?: number, envFrom?: string) => {
|
|
459
|
+
const preamble =
|
|
460
|
+
envFrom !== undefined
|
|
461
|
+
? `FROM ${envFrom}`
|
|
462
|
+
: source.dockerfile !== undefined && isInlineDockerfile(source.dockerfile)
|
|
463
|
+
? String(source.dockerfile.content).trimEnd()
|
|
464
|
+
: `FROM ${source.image ?? "oven/bun:1"}`;
|
|
465
|
+
const lines = [
|
|
466
|
+
preamble,
|
|
467
|
+
`WORKDIR /app`,
|
|
468
|
+
`COPY index.mjs /app/index.mjs`,
|
|
469
|
+
// Copy any additional rolldown chunks (`chunk-XXX.js`,
|
|
470
|
+
// `BunServices-YYY.js`, …). Non-trivial bundles always emit at
|
|
471
|
+
// least one; minimal bundles emit none and the COPY no-ops.
|
|
472
|
+
`COPY *.js /app/`,
|
|
473
|
+
];
|
|
474
|
+
if (port !== undefined) {
|
|
475
|
+
lines.push(`ENV PORT=${String(port)}`, `EXPOSE ${String(port)}`);
|
|
476
|
+
}
|
|
477
|
+
lines.push(`ENTRYPOINT ["bun", "/app/index.mjs"]`);
|
|
478
|
+
return `${lines.join("\n")}\n`;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Build (or mirror) and push an image to `target`, pushing with the
|
|
483
|
+
* target's credentials or — when it has none — the build machine's own
|
|
484
|
+
* Docker login.
|
|
485
|
+
*/
|
|
486
|
+
const push = (
|
|
487
|
+
imageUri: string,
|
|
488
|
+
credentials: RegistryCredentials | undefined,
|
|
489
|
+
platform?: string,
|
|
490
|
+
) =>
|
|
491
|
+
(credentials === undefined
|
|
492
|
+
? docker.run([
|
|
493
|
+
"image",
|
|
494
|
+
"push",
|
|
495
|
+
...(platform !== undefined ? ["--platform", platform] : []),
|
|
496
|
+
imageUri,
|
|
497
|
+
])
|
|
498
|
+
: docker.image.push(imageUri, credentials, platform)
|
|
499
|
+
).pipe(
|
|
500
|
+
// Pushes are idempotent; transient registry-transport failures
|
|
501
|
+
// (Docker Desktop's embedded proxy timing out under load, registry
|
|
502
|
+
// 503s, credential helper contention) resolve on a bounded re-attempt.
|
|
503
|
+
Effect.retry({
|
|
504
|
+
while: (): boolean => true,
|
|
505
|
+
schedule: Schedule.exponential("2 seconds"),
|
|
506
|
+
times: 3,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const buildAndPush = Effect.fnUntraced(function* <R>(
|
|
511
|
+
target: ImageRegistryTarget<R>,
|
|
512
|
+
options: {
|
|
513
|
+
imageUri: string;
|
|
514
|
+
context: string;
|
|
515
|
+
dockerfile?: string;
|
|
516
|
+
platform?: string;
|
|
517
|
+
},
|
|
518
|
+
) {
|
|
519
|
+
const credentials = yield* target.credentials;
|
|
520
|
+
yield* docker.image.build({
|
|
521
|
+
tag: options.imageUri,
|
|
522
|
+
context: options.context,
|
|
523
|
+
file: options.dockerfile,
|
|
524
|
+
platform: options.platform,
|
|
525
|
+
});
|
|
526
|
+
yield* push(options.imageUri, credentials, options.platform);
|
|
527
|
+
return options.imageUri;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Resolve the image for a props bag: resolve the target repository,
|
|
532
|
+
* compute the content-addressed tag, then build/mirror + push only when
|
|
533
|
+
* that exact tag is not already in the registry (crash-safe convergence).
|
|
534
|
+
*/
|
|
535
|
+
const resolve = Effect.fnUntraced(function* <R>(
|
|
536
|
+
options: ResolveContainerImageOptions,
|
|
537
|
+
target: ImageRegistryTarget<R>,
|
|
538
|
+
) {
|
|
539
|
+
const { id, source, session } = options;
|
|
540
|
+
const platform = options.platform ?? "linux/amd64";
|
|
541
|
+
const kind = imageSourceKind(source);
|
|
542
|
+
if (kind === undefined) {
|
|
543
|
+
return yield* Effect.die(
|
|
544
|
+
new Error(
|
|
545
|
+
`'${id}' must declare exactly one image source: 'main' (bundled Effect program), 'context' (Dockerfile build), or 'image' (registry reference)`,
|
|
546
|
+
),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
yield* validateImageSource(id, source);
|
|
551
|
+
|
|
552
|
+
const repositoryUri = yield* target.repositoryUri;
|
|
553
|
+
|
|
554
|
+
if (kind === "main") {
|
|
555
|
+
// Bundle → hash → (skip if pushed) → materialize generated Dockerfile
|
|
556
|
+
// → build + push.
|
|
557
|
+
const df = source.dockerfile;
|
|
558
|
+
if (df !== undefined && isInlineDockerfile(df) && typeof df.content !== "string") {
|
|
559
|
+
return yield* Effect.die(
|
|
560
|
+
new Error(
|
|
561
|
+
`'${id}': inline dockerfile content did not resolve to a string — Outputs in Dockerfile.inline must be resolvable at deploy time`,
|
|
562
|
+
),
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
yield* session.note(`Bundling ${id} program...`);
|
|
566
|
+
const { bundled, dockerfile, codeHash } = yield* computeMainCodeHash({
|
|
567
|
+
source: source as BundledImageSource,
|
|
568
|
+
isExternal: options.isExternal,
|
|
569
|
+
bootstrap: options.bootstrap,
|
|
570
|
+
port: options.port,
|
|
571
|
+
platform,
|
|
572
|
+
});
|
|
573
|
+
const imageUri = `${repositoryUri}:${codeHash}`;
|
|
574
|
+
|
|
575
|
+
if (yield* target.hasTag(codeHash)) {
|
|
576
|
+
return { imageUri, repositoryUri, codeHash };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// A path-`dockerfile` environment builds first as a local stage in the
|
|
580
|
+
// USER's context (so its COPYs resolve), then the generated Dockerfile
|
|
581
|
+
// FROMs the local tag and layers the bundle on top.
|
|
582
|
+
let envFrom: string | undefined;
|
|
583
|
+
if (df !== undefined && !isInlineDockerfile(df)) {
|
|
584
|
+
const env = yield* resolveContextPaths({
|
|
585
|
+
context: source.context!,
|
|
586
|
+
dockerfile: df,
|
|
587
|
+
});
|
|
588
|
+
envFrom = `alchemy-env-${id.toLowerCase()}:${codeHash}`;
|
|
589
|
+
yield* session.note(`Building environment image for ${id}...`);
|
|
590
|
+
yield* docker.image.build({
|
|
591
|
+
context: env.context,
|
|
592
|
+
file: env.dockerfile,
|
|
593
|
+
tag: envFrom,
|
|
594
|
+
platform,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const finalDockerfile =
|
|
598
|
+
envFrom === undefined
|
|
599
|
+
? dockerfile
|
|
600
|
+
: generateDockerfile(source as BundledImageSource, options.port, envFrom);
|
|
601
|
+
|
|
602
|
+
const realMain = yield* resolveMainPath((source as BundledImageSource).main);
|
|
603
|
+
const contextDir = yield* getStableContextDir(realMain, dotAlchemy, `${id}-image`);
|
|
604
|
+
yield* docker.materialize({
|
|
605
|
+
context: contextDir,
|
|
606
|
+
dockerfile: finalDockerfile,
|
|
607
|
+
// Entry chunk becomes `index.mjs`; all other chunks keep their
|
|
608
|
+
// emitted `*.js` names so the entry's relative imports resolve.
|
|
609
|
+
files: bundled.files.map((file, index) => ({
|
|
610
|
+
path: index === 0 ? "index.mjs" : file.path,
|
|
611
|
+
content: file.content,
|
|
612
|
+
})),
|
|
613
|
+
});
|
|
614
|
+
yield* session.note(`Building container image ${imageUri}...`);
|
|
615
|
+
yield* buildAndPush(target, {
|
|
616
|
+
imageUri,
|
|
617
|
+
context: contextDir,
|
|
618
|
+
platform,
|
|
619
|
+
});
|
|
620
|
+
yield* session.note(`Pushed ${imageUri}`);
|
|
621
|
+
return { imageUri, repositoryUri, codeHash };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (kind === "image") {
|
|
625
|
+
// Mirror: pull → tag → push. Content-addressed on the image ref, so
|
|
626
|
+
// an already-mirrored ref is a no-op.
|
|
627
|
+
const ref = (source as RegistryImageSource).image;
|
|
628
|
+
const codeHash = (yield* computeStaticSourceHash(source, platform))!;
|
|
629
|
+
const imageUri = `${repositoryUri}:${codeHash}`;
|
|
630
|
+
|
|
631
|
+
if (yield* target.hasTag(codeHash)) {
|
|
632
|
+
return { imageUri, repositoryUri, codeHash };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
yield* session.note(`Pulling container image ${ref}...`);
|
|
636
|
+
// A registry pull can wedge indefinitely under Docker Hub throttling /
|
|
637
|
+
// credential-helper contention — bound it so a stuck pull fails the
|
|
638
|
+
// deploy loudly instead of hanging the plan.
|
|
639
|
+
yield* docker.image.pull(ref, platform).pipe(Effect.timeout("4 minutes"));
|
|
640
|
+
yield* docker.image.tag(ref, imageUri);
|
|
641
|
+
yield* session.note(`Pushing mirrored image ${imageUri}...`);
|
|
642
|
+
const credentials = yield* target.credentials;
|
|
643
|
+
// Pin the platform on push: with the containerd image store a bare
|
|
644
|
+
// push of a multi-arch tag sends every locally-present variant — a
|
|
645
|
+
// stale other-arch variant in the local cache would reach the
|
|
646
|
+
// registry and the container would crash with `exec format error`.
|
|
647
|
+
yield* push(imageUri, credentials, platform);
|
|
648
|
+
yield* session.note(`Pushed ${imageUri}`);
|
|
649
|
+
return { imageUri, repositoryUri, codeHash };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// kind === "context": docker build the user's Dockerfile — from a path
|
|
653
|
+
// (in their context) or from inline content (empty stable context).
|
|
654
|
+
const externalDf = (source as DockerfileImageSource).dockerfile;
|
|
655
|
+
if (externalDf !== undefined && isInlineDockerfile(externalDf)) {
|
|
656
|
+
if (typeof externalDf.content !== "string") {
|
|
657
|
+
return yield* Effect.die(
|
|
658
|
+
new Error(
|
|
659
|
+
`'${id}': inline dockerfile content did not resolve to a string — Outputs in Dockerfile.inline must be resolvable at deploy time`,
|
|
660
|
+
),
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const codeHash = (yield* computeStaticSourceHash(source, platform))!;
|
|
664
|
+
const imageUri = `${repositoryUri}:${codeHash}`;
|
|
665
|
+
if (yield* target.hasTag(codeHash)) {
|
|
666
|
+
return { imageUri, repositoryUri, codeHash };
|
|
667
|
+
}
|
|
668
|
+
const contextDir = yield* getStableContextDir(dotAlchemy, dotAlchemy, `${id}-image`);
|
|
669
|
+
yield* docker.materialize({
|
|
670
|
+
context: contextDir,
|
|
671
|
+
dockerfile: externalDf.content,
|
|
672
|
+
files: [],
|
|
673
|
+
});
|
|
674
|
+
yield* session.note(`Building container image ${imageUri}...`);
|
|
675
|
+
yield* buildAndPush(target, {
|
|
676
|
+
imageUri,
|
|
677
|
+
context: contextDir,
|
|
678
|
+
platform,
|
|
679
|
+
});
|
|
680
|
+
yield* session.note(`Pushed ${imageUri}`);
|
|
681
|
+
return { imageUri, repositoryUri, codeHash };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const { context, dockerfile } = yield* resolveContextPaths({
|
|
685
|
+
context: (source as DockerfileImageSource).context!,
|
|
686
|
+
dockerfile: externalDf,
|
|
687
|
+
});
|
|
688
|
+
const codeHash = (yield* computeStaticSourceHash(source, platform))!;
|
|
689
|
+
const imageUri = `${repositoryUri}:${codeHash}`;
|
|
690
|
+
|
|
691
|
+
if (yield* target.hasTag(codeHash)) {
|
|
692
|
+
return { imageUri, repositoryUri, codeHash };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
yield* session.note(`Building container image ${imageUri}...`);
|
|
696
|
+
yield* buildAndPush(target, {
|
|
697
|
+
imageUri,
|
|
698
|
+
context,
|
|
699
|
+
dockerfile,
|
|
700
|
+
platform,
|
|
701
|
+
});
|
|
702
|
+
yield* session.note(`Pushed ${imageUri}`);
|
|
703
|
+
return { imageUri, repositoryUri, codeHash };
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Content hash for ANY source kind without building or pushing an image.
|
|
708
|
+
*
|
|
709
|
+
* For `main` sources this runs the bundler (bootstrap entry included) so
|
|
710
|
+
* the hash reflects the exact image `resolve` would push — bootstrap
|
|
711
|
+
* template changes and user-code edits both surface as drift. Providers
|
|
712
|
+
* call this from `diff` and compare against `output.code.hash`; static
|
|
713
|
+
* sources (`context` / `image`) delegate to
|
|
714
|
+
* {@link computeStaticSourceHash}.
|
|
715
|
+
*/
|
|
716
|
+
const hash = Effect.fn(function* (options: {
|
|
717
|
+
source: ImageSourceLike;
|
|
718
|
+
platform?: string;
|
|
719
|
+
port?: number;
|
|
720
|
+
isExternal?: boolean;
|
|
721
|
+
bootstrap: (importPath: string) => string;
|
|
722
|
+
}) {
|
|
723
|
+
const platform = options.platform ?? "linux/amd64";
|
|
724
|
+
if (imageSourceKind(options.source) === "main") {
|
|
725
|
+
// Unresolved inline environment content (an Output) can't be hashed
|
|
726
|
+
// at plan time — return undefined so the diff defers to reconcile.
|
|
727
|
+
const df = options.source.dockerfile;
|
|
728
|
+
if (df !== undefined && isInlineDockerfile(df) && typeof df.content !== "string") {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
const { codeHash } = yield* computeMainCodeHash({
|
|
732
|
+
source: options.source as BundledImageSource,
|
|
733
|
+
isExternal: options.isExternal,
|
|
734
|
+
bootstrap: options.bootstrap,
|
|
735
|
+
port: options.port,
|
|
736
|
+
platform,
|
|
737
|
+
});
|
|
738
|
+
return codeHash;
|
|
739
|
+
}
|
|
740
|
+
return yield* computeStaticSourceHash(options.source, platform);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The rolldown watch plan for a `main` source — the exact input/output
|
|
745
|
+
* options `resolve` bundles with, reusable verbatim with `Bundle.watch`
|
|
746
|
+
* so a dev watcher observes the identical module graph and rebuild
|
|
747
|
+
* triggers.
|
|
748
|
+
*/
|
|
749
|
+
const watchMain = Effect.fn(function* (options: {
|
|
750
|
+
source: BundledImageSource;
|
|
751
|
+
isExternal?: boolean;
|
|
752
|
+
bootstrap: (importPath: string) => string;
|
|
753
|
+
}) {
|
|
754
|
+
const realMain = yield* resolveMainPath(options.source.main);
|
|
755
|
+
const cwd = yield* findCwdForBundle(realMain);
|
|
756
|
+
const opts = mainBundleOptions(
|
|
757
|
+
options.source,
|
|
758
|
+
realMain,
|
|
759
|
+
cwd,
|
|
760
|
+
options.isExternal ? undefined : virtualEntryPlugin(options.bootstrap),
|
|
761
|
+
);
|
|
762
|
+
return { ...opts, extra: options.source.build };
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
return { resolve, hash, watchMain };
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
/** The resolver service returned by {@link makeContainerImageSource}. */
|
|
769
|
+
export interface ContainerImageSource extends Effect.Success<typeof makeContainerImageSource> {}
|