From c3d9406e7cf43f423ffe24e1722b9c8ac69e6b2b Mon Sep 17 00:00:00 2001 From: Austin Mudd Date: Thu, 18 Jun 2026 14:38:02 -0700 Subject: [PATCH 01/25] feat(docker): add Docker resources --- examples/docker-postgres/README.md | 29 + examples/docker-postgres/alchemy.run.ts | 67 ++ examples/docker-postgres/package.json | 21 + examples/docker-postgres/tsconfig.json | 16 + packages/alchemy/package.json | 6 + packages/alchemy/src/Docker/Container.ts | 500 ++++++++++++++ packages/alchemy/src/Docker/DockerApi.ts | 613 ++++++++++++++++++ packages/alchemy/src/Docker/Image.ts | 325 ++++++++++ packages/alchemy/src/Docker/Network.ts | 161 +++++ packages/alchemy/src/Docker/Providers.ts | 35 + packages/alchemy/src/Docker/RemoteImage.ts | 113 ++++ packages/alchemy/src/Docker/Volume.ts | 176 +++++ packages/alchemy/src/Docker/index.ts | 7 + .../test/Docker/Docker.integration.test.ts | 393 +++++++++++ .../alchemy/test/Docker/DockerApi.test.ts | 241 +++++++ 15 files changed, 2703 insertions(+) create mode 100644 examples/docker-postgres/README.md create mode 100644 examples/docker-postgres/alchemy.run.ts create mode 100644 examples/docker-postgres/package.json create mode 100644 examples/docker-postgres/tsconfig.json create mode 100644 packages/alchemy/src/Docker/Container.ts create mode 100644 packages/alchemy/src/Docker/DockerApi.ts create mode 100644 packages/alchemy/src/Docker/Image.ts create mode 100644 packages/alchemy/src/Docker/Network.ts create mode 100644 packages/alchemy/src/Docker/Providers.ts create mode 100644 packages/alchemy/src/Docker/RemoteImage.ts create mode 100644 packages/alchemy/src/Docker/Volume.ts create mode 100644 packages/alchemy/src/Docker/index.ts create mode 100644 packages/alchemy/test/Docker/Docker.integration.test.ts create mode 100644 packages/alchemy/test/Docker/DockerApi.test.ts diff --git a/examples/docker-postgres/README.md b/examples/docker-postgres/README.md new file mode 100644 index 0000000000..21bc107831 --- /dev/null +++ b/examples/docker-postgres/README.md @@ -0,0 +1,29 @@ +# Docker PostgreSQL Example + +This example provisions PostgreSQL 18 Alpine with Docker resources: + +- `Docker.RemoteImage` pulls `postgres:18-alpine` +- `Docker.Network` creates an app network +- `Docker.Volume` creates persistent database storage +- `Docker.Container` starts PostgreSQL with a redacted password, a network alias, a named volume, and a host port +- `Docker.Container.inspect` returns the bound host port + +Docker resources use the active Docker CLI context. That can be Docker Desktop, a remote Docker host, an SSH context, or a CI runner daemon. + +These resources are separate from `Cloudflare.Container`. The bridge between Docker and cloud container platforms is an image reference or registry digest, not a swappable container object. + +## Commands + +```sh +bun install +POSTGRES_PASSWORD=change-me bun run --filter docker-postgres-example deploy +bun run --filter docker-postgres-example destroy +``` + +If `POSTGRES_PASSWORD` is omitted, the example uses a redacted development default. + +The stack publishes PostgreSQL on `localhost:15432`: + +```sh +psql postgres://alchemy:change-me@localhost:15432/app +``` diff --git a/examples/docker-postgres/alchemy.run.ts b/examples/docker-postgres/alchemy.run.ts new file mode 100644 index 0000000000..8d833b5ed6 --- /dev/null +++ b/examples/docker-postgres/alchemy.run.ts @@ -0,0 +1,67 @@ +import * as Alchemy from "alchemy"; +import * as Docker from "alchemy/Docker"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +const POSTGRES_PORT = 15432; +const POSTGRES_CONTAINER = "alchemy-example-postgres"; +const EMPTY_RUNTIME: Docker.ContainerRuntimeInfo = { ports: {} }; + +export default Alchemy.Stack( + "DockerPostgresExample", + { providers: Docker.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const password = yield* Config.redacted("POSTGRES_PASSWORD").pipe( + Config.withDefault(Redacted.make("alchemy-postgres")), + ); + + const image = yield* Docker.RemoteImage("postgres-image", { + name: "postgres", + tag: "18-alpine", + alwaysPull: false, + }); + const network = yield* Docker.Network("app-network"); + const data = yield* Docker.Volume("postgres-data"); + + const postgres = yield* Docker.Container("postgres", { + name: POSTGRES_CONTAINER, + image, + environment: { + POSTGRES_DB: "app", + POSTGRES_USER: "alchemy", + POSTGRES_PASSWORD: password, + }, + ports: [{ external: POSTGRES_PORT, internal: 5432 }], + volumes: [ + { + hostPath: data.name, + containerPath: "/var/lib/postgresql/data", + }, + ], + networks: [{ name: network.name, aliases: ["postgres"] }], + healthcheck: { + cmd: ["CMD-SHELL", "pg_isready -U alchemy -d app"], + interval: "5s", + timeout: "5s", + retries: 10, + }, + start: true, + }); + + const runtime = yield* Docker.Container.inspect(POSTGRES_CONTAINER).pipe( + Effect.catchTag("DockerCommandError", () => + Effect.succeed(EMPTY_RUNTIME), + ), + ); + + return { + container: postgres.name, + image: image.imageRef, + network: network.name, + volume: data.name, + hostPort: runtime.ports["5432/tcp"] ?? POSTGRES_PORT, + connectionString: `postgres://alchemy:***@localhost:${POSTGRES_PORT}/app`, + }; + }), +); diff --git a/examples/docker-postgres/package.json b/examples/docker-postgres/package.json new file mode 100644 index 0000000000..2f3c98a3d4 --- /dev/null +++ b/examples/docker-postgres/package.json @@ -0,0 +1,21 @@ +{ + "name": "docker-postgres-example", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy-effect.git", + "directory": "examples/docker-postgres" + }, + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "destroy": "alchemy destroy", + "build": "tsgo --noEmit -p tsconfig.json" + }, + "dependencies": { + "alchemy": "workspace:*", + "effect": "catalog:" + } +} diff --git a/examples/docker-postgres/tsconfig.json b/examples/docker-postgres/tsconfig.json new file mode 100644 index 0000000000..22f9b2cae1 --- /dev/null +++ b/examples/docker-postgres/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["alchemy.run.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index 13fb95e54f..e5f1cfc209 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -107,6 +107,12 @@ "worker": "./src/Bundle/index.ts", "import": "./lib/Bundle/index.js" }, + "./Docker": { + "types": "./lib/Docker/index.d.ts", + "bun": "./src/Docker/index.ts", + "worker": "./src/Docker/index.ts", + "import": "./lib/Docker/index.js" + }, "./ContentType": { "types": "./lib/ContentType.d.ts", "bun": "./src/ContentType.ts", diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts new file mode 100644 index 0000000000..fb2fb171d1 --- /dev/null +++ b/packages/alchemy/src/Docker/Container.ts @@ -0,0 +1,500 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { Unowned } from "../AdoptPolicy.ts"; +import { deepEqual, isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + connectNetwork, + createContainer, + disconnectNetwork, + durationToNanoseconds, + DockerCommandError, + inspectContainer, + normalizeDuration, + removeContainer, + startContainer, + stopContainer, + toRuntimeInfo, + type ContainerInfo, + type ContainerRuntimeInfo, + type ContainerStatus, + type Duration, + type HealthcheckConfig, + type NetworkMapping, + type PortMapping, + type SecretString, + type VolumeMapping, +} from "./DockerApi.ts"; + +export type { + ContainerRuntimeInfo, + ContainerStatus, + Duration, + HealthcheckConfig, + NetworkMapping, + PortMapping, + VolumeMapping, +}; + +export type ContainerImage = string | { imageRef: string }; + +export interface ContainerProps { + /** Image reference or Docker image resource. */ + image: ContainerImage; + /** + * Container name. + * + * @default Generated from stack, stage, logical id, and instance id. + */ + name?: string; + /** Command to run in the container. */ + command?: string[]; + /** Container environment variables. Use Redacted for secrets. */ + environment?: Record; + /** Host/container port mappings. */ + ports?: PortMapping[]; + /** Volume or bind mounts. */ + volumes?: VolumeMapping[]; + /** Restart policy. */ + restart?: "no" | "always" | "on-failure" | "unless-stopped"; + /** Networks to connect after create. */ + networks?: NetworkMapping[]; + /** Remove the container when it exits. @default false */ + removeOnExit?: boolean; + /** Start the container after creation/reconciliation. @default false */ + start?: boolean; + /** Docker healthcheck configuration. */ + healthcheck?: HealthcheckConfig; +} + +export interface Container extends Resource< + "Docker.Container", + ContainerProps, + { + /** Docker container id. */ + id: string; + /** Docker container name. */ + name: string; + /** Docker container state. */ + state: ContainerStatus; + /** Creation timestamp in milliseconds since epoch. */ + createdAt: number; + /** Image reference used to create the container. */ + imageRef: string; + } +> {} + +const inspectRuntime = ( + container: string | { name: string }, +): Effect.Effect => + Effect.gen(function* () { + const name = typeof container === "string" ? container : container.name; + const info = yield* inspectContainer(name); + if (!info) { + return yield* Effect.fail( + new DockerCommandError({ + command: `docker container inspect ${name}`, + stderr: `Docker container not found: ${name}`, + exitCode: 1, + message: `Docker container not found: ${name}`, + }), + ); + } + return toRuntimeInfo(info); + }); + +/** + * A Docker container managed through the active Docker context. + * + * This resource creates, starts, stops, inspects, and removes containers through + * the Docker CLI. It is not interchangeable with `Cloudflare.Container`, which + * manages Cloudflare's container platform; use pushed image references to bridge + * Docker-built images into cloud container runtimes. + * + * @resource + * + * @section Running Containers + * @example Nginx with a published port + * ```typescript + * const nginx = yield* Docker.Container("nginx", { + * image: "nginx:alpine", + * ports: [{ external: 8080, internal: 80 }], + * start: true, + * }); + * ``` + * + * @section Secret Environment + * @example Redacted env var + * ```typescript + * const password = yield* Config.redacted("POSTGRES_PASSWORD"); + * const db = yield* Docker.Container("postgres", { + * image: "postgres:18-alpine", + * environment: { + * POSTGRES_PASSWORD: password, + * }, + * start: true, + * }); + * ``` + * + * @section Networks and Volumes + * @example PostgreSQL with persistent storage + * ```typescript + * const network = yield* Docker.Network("app-network"); + * const data = yield* Docker.Volume("postgres-data"); + * const postgresName = "app-postgres"; + * yield* Docker.Container("postgres", { + * name: postgresName, + * image: "postgres:18-alpine", + * ports: [{ external: 15432, internal: 5432 }], + * volumes: [{ hostPath: data.name, containerPath: "/var/lib/postgresql/data" }], + * networks: [{ name: network.name, aliases: ["postgres"] }], + * start: true, + * }); + * const runtime = yield* Docker.Container.inspect(postgresName); + * ``` + */ +export const Container = Resource("Docker.Container")({ + inspect: inspectRuntime, +}); + +export const inspect = inspectRuntime; + +const containerName = (id: string, props: ContainerProps, instanceId: string) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }); + +export const imageRefOf = (image: ContainerImage): string => + typeof image === "string" ? image : image.imageRef; + +const toContainerAttributes = ( + info: ContainerInfo, + imageRef: string, +): Container["Attributes"] => ({ + id: info.Id, + name: infoName(info), + state: info.State.Status, + createdAt: Date.parse(info.Created) || Date.now(), + imageRef, +}); + +const infoName = (info: ContainerInfo) => { + const name = info.Name; + return typeof name === "string" ? name.replace(/^\//, "") : info.Id; +}; + +export const normalizeEnvironment = ( + environment: Record | undefined, +): Record => + Object.fromEntries( + Object.entries(environment ?? {}).map(([key, value]) => [ + key, + typeof value === "string" ? value : Redacted.value(value), + ]), + ); + +const normalizePortMappings = ( + ports: PortMapping[] | undefined, +): Map => { + const map = new Map(); + for (const port of ports ?? []) { + map.set(`${port.external}`, `${port.internal}/${port.protocol ?? "tcp"}`); + } + return map; +}; + +const normalizeVolumeMappings = ( + volumes: VolumeMapping[] | undefined, +): Set => { + const set = new Set(); + for (const volume of volumes ?? []) { + set.add( + `${volume.hostPath}:${volume.containerPath}${volume.readOnly ? ":ro" : ""}`, + ); + } + return set; +}; + +export const compareEnv = ( + desired: Record | undefined, + actual: string[] | null, +): boolean => { + const desiredEntries = Object.entries(desired ?? {}).sort(([a], [b]) => + a.localeCompare(b), + ); + const actualEntries = (actual ?? []) + .flatMap((entry) => { + const index = entry.indexOf("="); + return index === -1 + ? [] + : [[entry.slice(0, index), entry.slice(index + 1)] as const]; + }) + .filter(([key]) => key in (desired ?? {})) + .sort(([a], [b]) => a.localeCompare(b)); + return deepEqual(desiredEntries, actualEntries); +}; + +export const comparePorts = ( + desired: PortMapping[] | undefined, + actual: Record< + string, + Array<{ HostIp: string; HostPort: string }> | null + > | null, +): boolean => { + const desiredMap = normalizePortMappings(desired); + const actualMap = new Map(); + for (const [containerPort, bindings] of Object.entries(actual ?? {})) { + const hostPort = bindings?.[0]?.HostPort; + if (hostPort) actualMap.set(hostPort, containerPort); + } + return deepEqual([...desiredMap.entries()], [...actualMap.entries()]); +}; + +export const compareVolumes = ( + desired: VolumeMapping[] | undefined, + actual: string[] | null, +): boolean => deepEqual([...normalizeVolumeMappings(desired)], actual ?? []); + +export const compareHealthcheck = ( + desired: HealthcheckConfig | undefined, + actual: + | { + Test: string[] | null; + Interval?: number; + Timeout?: number; + Retries?: number; + StartPeriod?: number; + StartInterval?: number; + } + | null + | undefined, +): boolean => { + if (!desired) return true; + if (!actual) return false; + const desiredCommand = Array.isArray(desired.cmd) + ? desired.cmd.join(" ") + : desired.cmd; + const actualCommand = actual.Test ? actual.Test.slice(1).join(" ") : ""; + return ( + desiredCommand === actualCommand && + durationToNanoseconds(desired.interval) === (actual.Interval ?? 0) && + durationToNanoseconds(desired.timeout) === (actual.Timeout ?? 0) && + (desired.retries ?? 0) === (actual.Retries ?? 0) && + durationToNanoseconds(desired.startPeriod) === (actual.StartPeriod ?? 0) && + durationToNanoseconds(desired.startInterval) === (actual.StartInterval ?? 0) + ); +}; + +export const compareRestartPolicy = ( + desired: ContainerProps["restart"], + actual: ContainerInfo["HostConfig"]["RestartPolicy"], +): boolean => (desired ?? "no") === (actual?.Name || "no"); + +export const shouldReplaceContainer = ( + imageRef: string, + props: ContainerProps, + info: ContainerInfo, +): boolean => { + if (info.Config.Image !== imageRef) return true; + const actualCommand = info.Config.Cmd ?? []; + if (props.command && !deepEqual(props.command, actualCommand)) return true; + if (!compareEnv(normalizeEnvironment(props.environment), info.Config.Env)) { + return true; + } + if (!comparePorts(props.ports, info.HostConfig.PortBindings)) return true; + if (!compareVolumes(props.volumes, info.HostConfig.Binds)) return true; + if (!compareHealthcheck(props.healthcheck, info.Config.Healthcheck)) { + return true; + } + if (!compareRestartPolicy(props.restart, info.HostConfig.RestartPolicy)) { + return true; + } + if ((props.removeOnExit ?? false) !== info.HostConfig.AutoRemove) { + return true; + } + return false; +}; + +const networkChanges = ( + props: ContainerProps, + info: ContainerInfo, +): { connect: NetworkMapping[]; disconnect: string[] } => { + const current = info.NetworkSettings.Networks ?? {}; + const currentNames = new Set(Object.keys(current)); + const desired = new Map((props.networks ?? []).map((n) => [n.name, n])); + const connect: NetworkMapping[] = []; + const disconnect: string[] = []; + for (const network of currentNames) { + if (!desired.has(network) && network !== "bridge") { + disconnect.push(network); + } + } + for (const [name, network] of desired) { + if (!currentNames.has(name)) { + connect.push(network); + continue; + } + const desiredAliases = network.aliases ?? []; + const actualAliases = current[name]?.Aliases ?? []; + if ( + name !== "bridge" && + desiredAliases.length > 0 && + !desiredAliases.every((alias) => actualAliases.includes(alias)) + ) { + disconnect.push(name); + connect.push(network); + } + } + return { connect, disconnect }; +}; + +const reconcileNetworksAndState = Effect.fn(function* ( + name: string, + props: ContainerProps, + info: ContainerInfo, +) { + const changes = networkChanges(props, info); + for (const network of changes.disconnect) { + yield* disconnectNetwork(info.Id, network); + } + for (const network of changes.connect) { + yield* connectNetwork(info.Id, network); + } + if (props.start && info.State.Status !== "running") { + yield* startContainer(info.Id); + } + if (props.start === false && info.State.Status === "running") { + yield* stopContainer(info.Id); + } + return yield* inspectContainer(name); +}); + +const createAndInspect = Effect.fn(function* ( + name: string, + imageRef: string, + props: ContainerProps, +) { + const id = yield* createContainer({ + image: imageRef, + name, + command: props.command, + environment: props.environment, + ports: props.ports, + volumes: props.volumes, + restart: props.restart, + removeOnExit: props.removeOnExit, + healthcheck: props.healthcheck, + }); + for (const network of props.networks ?? []) { + yield* connectNetwork(id, network); + } + if (props.start) { + yield* startContainer(id); + } + const info = yield* inspectContainer(name); + if (!info) { + return yield* Effect.die( + `Docker container was created but could not be inspected: ${name}`, + ); + } + return info; +}); + +export const ContainerProvider = () => + Provider.succeed(Container, { + list: () => Effect.succeed([]), + read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { + const name = yield* containerName(id, olds, instanceId); + const info = yield* inspectContainer(name); + if (!info) return undefined; + const attrs = toContainerAttributes(info, imageRefOf(olds.image)); + return output ? attrs : Unowned(attrs); + }), + diff: Effect.fnUntraced(function* ({ news, olds }) { + if (!isResolved(news)) return undefined; + const replaceShape = (props: ContainerProps) => ({ + name: props.name, + image: imageRefOf(props.image), + command: props.command ?? [], + environment: normalizeEnvironment(props.environment), + ports: props.ports ?? [], + volumes: props.volumes ?? [], + restart: props.restart ?? "no", + removeOnExit: props.removeOnExit ?? false, + healthcheck: props.healthcheck + ? { + ...props.healthcheck, + interval: + props.healthcheck.interval === undefined + ? undefined + : normalizeDuration(props.healthcheck.interval), + timeout: + props.healthcheck.timeout === undefined + ? undefined + : normalizeDuration(props.healthcheck.timeout), + startPeriod: + props.healthcheck.startPeriod === undefined + ? undefined + : normalizeDuration(props.healthcheck.startPeriod), + startInterval: + props.healthcheck.startInterval === undefined + ? undefined + : normalizeDuration(props.healthcheck.startInterval), + } + : undefined, + }); + if (!deepEqual(replaceShape(olds), replaceShape(news))) { + return { action: "replace" as const, deleteFirst: true }; + } + if ( + !deepEqual(olds.networks ?? [], news.networks ?? []) || + (olds.start ?? false) !== (news.start ?? false) + ) { + return { action: "update" as const }; + } + }), + reconcile: Effect.fnUntraced(function* ({ + id, + instanceId, + news, + output, + session, + }) { + const name = yield* containerName(id, news, instanceId); + const imageRef = imageRefOf(news.image); + const live = yield* inspectContainer(name); + + if (live && shouldReplaceContainer(imageRef, news, live)) { + yield* session.note(`Replacing Docker container: ${name}`); + yield* removeContainer(name, true); + } else if (live) { + const current = yield* reconcileNetworksAndState(name, news, live); + if (!current) { + return yield* Effect.die( + `Docker container disappeared during reconcile: ${name}`, + ); + } + return toContainerAttributes(current, imageRef); + } + + yield* session.note( + output + ? `Recreating Docker container: ${name}` + : `Creating Docker container: ${name}`, + ); + const created = yield* createAndInspect(name, imageRef, news); + return toContainerAttributes(created, imageRef); + }), + delete: Effect.fnUntraced(function* ({ output, session }) { + yield* session.note(`Removing Docker container: ${output.name}`); + yield* stopContainer(output.name); + yield* removeContainer(output.name, true); + }), + }); diff --git a/packages/alchemy/src/Docker/DockerApi.ts b/packages/alchemy/src/Docker/DockerApi.ts new file mode 100644 index 0000000000..d644efe3a6 --- /dev/null +++ b/packages/alchemy/src/Docker/DockerApi.ts @@ -0,0 +1,613 @@ +import { + DockerCommandError, + dockerLogin, + dockerTag, + getDockerImageId, + runDockerCommand, + type RegistryAuth, +} from "../Bundle/Docker.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; + +export { DockerCommandError, runDockerCommand }; + +export type SecretString = string | Redacted.Redacted; + +export type Duration = number | `${number}${"ms" | "s" | "m" | "h"}`; + +export interface DockerCommandSpec { + readonly args: ReadonlyArray; + readonly env?: Record; +} + +export interface VolumeInfo { + CreatedAt: string; + Driver: string; + Labels: Record | null; + Mountpoint: string; + Name: string; + Options: Record | null; + Scope: string; +} + +export interface NetworkInfo { + Name: string; + Id: string; + Created: string; + Scope: string; + Driver: string; + EnableIPv6: boolean; + Labels: Record | null; +} + +export type ContainerStatus = + | "created" + | "running" + | "paused" + | "restarting" + | "removing" + | "exited" + | "dead"; + +export interface ContainerInfo { + Id: string; + Name?: string; + State: { Status: ContainerStatus }; + Created: string; + Config: { + Image: string; + Cmd: string[] | null; + Env: string[] | null; + Healthcheck?: { + Test: string[] | null; + Interval?: number; + Timeout?: number; + Retries?: number; + StartPeriod?: number; + StartInterval?: number; + } | null; + }; + HostConfig: { + PortBindings: Record< + string, + Array<{ HostIp: string; HostPort: string }> | null + > | null; + Binds: string[] | null; + RestartPolicy: { + Name: string; + MaximumRetryCount: number; + }; + AutoRemove: boolean; + }; + NetworkSettings: { + Networks: Record< + string, + { + NetworkID: string; + Aliases: string[] | null; + } + > | null; + Ports?: Record< + string, + Array<{ HostIp: string; HostPort: string }> | null + > | null; + }; +} + +export interface ImageInspectInfo { + Id: string; + RepoTags?: string[] | null; + RepoDigests?: string[] | null; +} + +export interface ContainerRuntimeInfo { + /** + * Map of internal container ports to their bound host ports. + * Format: `"80/tcp" -> 8080`. + */ + ports: Record; +} + +export interface PortMapping { + /** External port on the host. */ + external: number | string; + /** Internal port inside the container. */ + internal: number | string; + /** Protocol used for the mapping. @default "tcp" */ + protocol?: "tcp" | "udp"; +} + +export interface VolumeMapping { + /** Host path or named volume source. */ + hostPath: string; + /** Container path. */ + containerPath: string; + /** Mount read-only. @default false */ + readOnly?: boolean; +} + +export interface NetworkMapping { + /** Network name or ID. */ + name: string; + /** Network aliases for the container. */ + aliases?: string[]; +} + +export interface HealthcheckConfig { + /** Command to run for health checks. */ + cmd: string[] | string; + /** Time between checks. */ + interval?: Duration; + /** Maximum time a check may run. */ + timeout?: Duration; + /** Consecutive failures before unhealthy. */ + retries?: number; + /** Startup grace period. */ + startPeriod?: Duration; + /** Check interval during startup. Requires Docker API 1.44+. */ + startInterval?: Duration; +} + +export interface ContainerCreateOptions { + image: string; + name: string; + command?: string[]; + environment?: Record; + ports?: PortMapping[]; + volumes?: VolumeMapping[]; + restart?: "no" | "always" | "on-failure" | "unless-stopped"; + removeOnExit?: boolean; + healthcheck?: HealthcheckConfig; +} + +export interface DockerBuildCommandOptions { + tag: string; + context: string; + dockerfile?: string; + platform?: string; + target?: string; + args?: Record; + cacheFrom?: string[]; + cacheTo?: string[]; + options?: string[]; +} + +export interface RegistryPushCredentials { + server: string; + username: string; + password: SecretString; +} + +export const unwrapSecretString = (value: SecretString): string => + typeof value === "string" ? value : Redacted.value(value); + +export function normalizeDuration(duration: Duration): string { + if (typeof duration === "number") { + return `${duration}s`; + } + if (!/^(\d+(?:\.\d+)?)(ms|s|m|h)$/.test(duration)) { + throw new Error( + `Invalid duration format: "${duration}". Expected number followed by ms, s, m, or h.`, + ); + } + return duration; +} + +export function durationToNanoseconds(duration: Duration | undefined): number { + if (duration === undefined) return 0; + const normalized = normalizeDuration(duration); + const match = normalized.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/); + if (!match) return 0; + const value = Number(match[1]); + switch (match[2]) { + case "ms": + return value * 1_000_000; + case "s": + return value * 1_000_000_000; + case "m": + return value * 60 * 1_000_000_000; + case "h": + return value * 60 * 60 * 1_000_000_000; + default: + return 0; + } +} + +export const normalizeLabels = ( + labels: + | Record + | ReadonlyArray<{ name: string; value: string }> + | undefined, +): Record => { + if (!labels) return {}; + const value = labels as + | Record + | ReadonlyArray<{ name: string; value: string }>; + if (Array.isArray(value)) { + return Object.fromEntries(value.map((label) => [label.name, label.value])); + } + return { ...(value as Record) }; +}; + +export const buildVolumeCreateArgs = (input: { + name: string; + driver?: string; + driverOpts?: Record; + labels?: Record; +}): string[] => { + const args = [ + "volume", + "create", + "--name", + input.name, + "--driver", + input.driver ?? "local", + ]; + for (const [key, value] of Object.entries(input.driverOpts ?? {})) { + args.push("--opt", `${key}=${value}`); + } + for (const [key, value] of Object.entries(input.labels ?? {})) { + args.push("--label", `${key}=${value}`); + } + return args; +}; + +export const buildNetworkCreateArgs = (input: { + name: string; + driver?: string; + enableIPv6?: boolean; + labels?: Record; +}): string[] => { + const args = ["network", "create", "--driver", input.driver ?? "bridge"]; + if (input.enableIPv6) { + args.push("--ipv6"); + } + for (const [key, value] of Object.entries(input.labels ?? {})) { + args.push("--label", `${key}=${value}`); + } + args.push(input.name); + return args; +}; + +export const buildImageBuildArgs = ( + options: DockerBuildCommandOptions, +): string[] => { + const args = ["build", "-t", options.tag]; + if (options.platform) args.push("--platform", options.platform); + if (options.target) args.push("--target", options.target); + for (const source of options.cacheFrom ?? []) { + args.push("--cache-from", source); + } + for (const target of options.cacheTo ?? []) { + args.push("--cache-to", target); + } + for (const [key, value] of Object.entries(options.args ?? {})) { + args.push("--build-arg", `${key}=${value}`); + } + if (options.options?.length) { + args.push(...options.options); + } + if (options.dockerfile) { + args.push("-f", options.dockerfile); + } + args.push(options.context); + return args; +}; + +export const buildContainerCreateCommand = ( + options: ContainerCreateOptions, +): DockerCommandSpec => { + const args = ["create", "--name", options.name]; + const env: Record = {}; + + for (const port of options.ports ?? []) { + const protocol = port.protocol ?? "tcp"; + args.push("-p", `${port.external}:${port.internal}/${protocol}`); + } + + for (const [key, value] of Object.entries(options.environment ?? {})) { + // `--env KEY` reads the value from the child process environment, so + // Redacted secrets are never embedded in argv or DockerCommandError.command. + args.push("--env", key); + env[key] = unwrapSecretString(value); + } + + for (const volume of options.volumes ?? []) { + const readOnly = volume.readOnly ? ":ro" : ""; + args.push("-v", `${volume.hostPath}:${volume.containerPath}${readOnly}`); + } + + if (options.restart) { + args.push("--restart", options.restart); + } + if (options.removeOnExit) { + args.push("--rm"); + } + + if (options.healthcheck) { + const healthcheck = options.healthcheck; + args.push( + "--health-cmd", + Array.isArray(healthcheck.cmd) + ? healthcheck.cmd.join(" ") + : healthcheck.cmd, + ); + if (healthcheck.interval !== undefined) { + args.push("--health-interval", normalizeDuration(healthcheck.interval)); + } + if (healthcheck.timeout !== undefined) { + args.push("--health-timeout", normalizeDuration(healthcheck.timeout)); + } + if (healthcheck.retries !== undefined) { + args.push("--health-retries", String(healthcheck.retries)); + } + if (healthcheck.startPeriod !== undefined) { + args.push( + "--health-start-period", + normalizeDuration(healthcheck.startPeriod), + ); + } + if (healthcheck.startInterval !== undefined) { + args.push( + "--health-start-interval", + normalizeDuration(healthcheck.startInterval), + ); + } + } + + args.push(options.image); + if (options.command?.length) { + args.push(...options.command); + } + + return { + args, + env: Object.keys(env).length ? env : undefined, + }; +}; + +export const toRuntimeInfo = (info: ContainerInfo): ContainerRuntimeInfo => { + const ports: Record = {}; + + for (const [internal, bindings] of Object.entries( + info.NetworkSettings.Ports ?? {}, + )) { + const hostPort = bindings?.[0]?.HostPort; + if (hostPort) ports[internal] = Number.parseInt(hostPort, 10); + } + + for (const [internal, bindings] of Object.entries( + info.HostConfig.PortBindings ?? {}, + )) { + const hostPort = bindings?.[0]?.HostPort; + if (hostPort && !(internal in ports)) { + ports[internal] = Number.parseInt(hostPort, 10); + } + } + + return { ports }; +}; + +export const parseRepoDigest = ( + imageRef: string, + output: string, +): string | undefined => { + const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output); + if (!match) return undefined; + return `${repositoryFromImageRef(imageRef)}@${match[1]}`; +}; + +export const repositoryFromImageRef = (imageRef: string): string => { + const withoutDigest = imageRef.includes("@") + ? imageRef.slice(0, imageRef.indexOf("@")) + : imageRef; + const tagSeparator = withoutDigest.lastIndexOf(":"); + const pathSeparator = withoutDigest.lastIndexOf("/"); + return tagSeparator > pathSeparator + ? withoutDigest.slice(0, tagSeparator) + : withoutDigest; +}; + +export const withRegistryHost = ( + imageRef: string, + registry: Pick, +): string => { + const registryHost = registry.server.replace(/\/$/, ""); + const firstSegment = imageRef.split("/")[0]; + const hasRegistryPrefix = + imageRef.includes("/") && + (firstSegment.includes(".") || + firstSegment.includes(":") || + firstSegment === "localhost"); + return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`; +}; + +const catchMissing = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.catchIf( + (error) => + /No such|not found|No such object|No such image|No such container/i.test( + error.message, + ), + () => Effect.succeed(undefined), + ), + ); + +export const inspectJson = ( + args: ReadonlyArray, +): Effect.Effect => + catchMissing( + runDockerCommand(args).pipe( + Effect.map(({ stdout }) => JSON.parse(stdout.trim()) as A), + ), + ); + +export const inspectVolume = (name: string) => + inspectJson(["volume", "inspect", name]).pipe( + Effect.map((volumes) => volumes?.[0]), + ); + +export const inspectNetwork = (name: string) => + inspectJson(["network", "inspect", name]).pipe( + Effect.map((networks) => networks?.[0]), + ); + +export const inspectContainer = (name: string) => + inspectJson(["container", "inspect", name]).pipe( + Effect.map((containers) => containers?.[0]), + ); + +export const inspectImage = (imageRef: string) => + inspectJson(["image", "inspect", imageRef]).pipe( + Effect.map((images) => images?.[0]), + ); + +export const createVolume = Effect.fn(function* (input: { + name: string; + driver?: string; + driverOpts?: Record; + labels?: Record; +}) { + const { stdout } = yield* runDockerCommand(buildVolumeCreateArgs(input)); + return stdout.trim(); +}); + +export const createNetwork = Effect.fn(function* (input: { + name: string; + driver?: string; + enableIPv6?: boolean; + labels?: Record; +}) { + const { stdout } = yield* runDockerCommand(buildNetworkCreateArgs(input)); + return stdout.trim(); +}); + +export const removeNetwork = (id: string) => + runDockerCommand(["network", "rm", id]).pipe( + Effect.catchIf( + (error) => /No such network|not found/i.test(error.message), + () => Effect.void, + ), + ); + +export const removeVolume = (name: string) => + runDockerCommand(["volume", "rm", name]).pipe( + Effect.catchIf( + (error) => /No such volume|not found/i.test(error.message), + () => Effect.void, + ), + Effect.asVoid, + ); + +export const removeContainer = (name: string, force = false) => + runDockerCommand(["rm", ...(force ? ["-f"] : []), name]).pipe( + Effect.catchIf( + (error) => /No such container|not found/i.test(error.message), + () => Effect.void, + ), + Effect.asVoid, + ); + +export const stopContainer = (name: string) => + runDockerCommand(["stop", name]).pipe( + Effect.catchIf( + (error) => + /No such container|not running|is not running/i.test(error.message), + () => Effect.void, + ), + Effect.asVoid, + ); + +export const startContainer = (name: string) => + runDockerCommand(["start", name]).pipe(Effect.asVoid); + +export const connectNetwork = (container: string, network: NetworkMapping) => { + const args = ["network", "connect"]; + for (const alias of network.aliases ?? []) { + args.push("--alias", alias); + } + args.push(network.name, container); + return runDockerCommand(args).pipe(Effect.asVoid); +}; + +export const disconnectNetwork = (container: string, network: string) => + runDockerCommand(["network", "disconnect", network, container]).pipe( + Effect.catchIf( + (error) => + /is not connected|No such network|No such container/i.test( + error.message, + ), + () => Effect.void, + ), + Effect.asVoid, + ); + +export const createContainer = Effect.fn(function* ( + options: ContainerCreateOptions, +) { + const command = buildContainerCreateCommand(options); + const { stdout } = yield* runDockerCommand(command.args, { + env: command.env, + }); + return stdout.trim(); +}); + +export const pullImage = (imageRef: string, options?: { platform?: string }) => + runDockerCommand([ + "pull", + ...(options?.platform ? ["--platform", options.platform] : []), + imageRef, + ]).pipe(Effect.asVoid); + +export const tagImage = dockerTag; + +export const buildImage = (options: DockerBuildCommandOptions) => + runDockerCommand(buildImageBuildArgs(options), { + cwd: options.context, + }).pipe(Effect.asVoid); + +export const imageId = getDockerImageId; + +export const pushImageToRegistry = Effect.fn(function* ( + imageRef: string, + credentials: RegistryPushCredentials, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const registryHost = credentials.server.replace(/\/$/, ""); + const targetImage = withRegistryHost(imageRef, credentials); + const configDir = yield* fs.makeTempDirectory({ + prefix: "alchemy-docker-", + }); + const env = { DOCKER_CONFIG: configDir }; + const auth: RegistryAuth = { + server: registryHost, + username: credentials.username, + password: unwrapSecretString(credentials.password), + }; + + return yield* Effect.gen(function* () { + yield* dockerLogin(auth, { env }); + if (targetImage !== imageRef) { + yield* dockerTag(imageRef, targetImage); + } + const { stdout, stderr } = yield* runDockerCommand(["push", targetImage], { + env, + }); + const repoDigest = parseRepoDigest(targetImage, `${stdout}\n${stderr}`); + return { + imageRef: targetImage, + repoDigest, + }; + }).pipe( + Effect.ensuring( + fs + .remove(path.resolve(configDir), { recursive: true }) + .pipe(Effect.catch(() => Effect.void)), + ), + ); +}); diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts new file mode 100644 index 0000000000..c0d43660cc --- /dev/null +++ b/packages/alchemy/src/Docker/Image.ts @@ -0,0 +1,325 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { hashDirectory, type MemoOptions } from "../Build/Memo.ts"; +import { deepEqual, isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + buildImage, + imageId, + inspectImage, + pullImage, + pushImageToRegistry, + repositoryFromImageRef, + tagImage, + withRegistryHost, + type RegistryPushCredentials, +} from "./DockerApi.ts"; + +export interface DockerBuildOptions { + /** + * Build context directory. + * + * @default Current working directory. + */ + context?: string; + /** + * Dockerfile path, relative to the context unless absolute. + * + * @default "Dockerfile" + */ + dockerfile?: string; + /** Target platform, e.g. `"linux/amd64"`. */ + platform?: string; + /** Docker build arguments. */ + args?: Record; + /** Multi-stage build target. */ + target?: string; + /** Cache sources passed as `--cache-from`. */ + cacheFrom?: string[]; + /** Cache destinations passed as `--cache-to`. */ + cacheTo?: string[]; + /** Additional Docker build options. */ + options?: string[]; + /** Files included in the build-context hash used for rebuild decisions. */ + memo?: MemoOptions; +} + +export interface ImageRegistry { + /** Registry host, e.g. `ghcr.io`. */ + server: string; + /** Registry username. */ + username: string; + /** Registry password. Use `Redacted.make(...)` or `Config.redacted(...)`. */ + password: Redacted.Redacted; +} + +export type ImageSource = + | string + | { imageRef: string; name?: string; kind?: "Image" | "RemoteImage" }; + +export type ImageProps = { + /** Image tag. @default "latest" */ + tag?: string; + /** Registry credentials for push. */ + registry?: ImageRegistry; + /** Skip registry push even when `registry` is set. @default false */ + skipPush?: boolean; +} & ( + | { + /** Existing image reference or another Docker image resource. */ + image: ImageSource; + build?: never; + name?: never; + } + | { + /** Repository/name for the built image. @default Logical id */ + name?: string; + /** Docker build configuration. */ + build: DockerBuildOptions; + image?: never; + } +); + +export interface Image extends Resource< + "Docker.Image", + ImageProps, + { + kind: "Image"; + /** Image repository/name without tag. */ + name: string; + /** Final image reference. Includes registry host when pushed there. */ + imageRef: string; + /** Local image id after build/tag when available. */ + imageId?: string; + /** Registry repository digest after push when available. */ + repoDigest?: string; + /** Tag used for the local image. */ + tag: string; + /** Build timestamp in milliseconds since epoch. */ + builtAt: number; + /** Hash of build-context files when `build` is used. */ + contextHash?: string; + } +> {} + +/** + * Builds, pulls, tags, and optionally pushes Docker images through the active + * Docker context. + * + * This resource uses the Docker CLI and whatever daemon or remote context the + * CLI is configured to target. It is separate from `Cloudflare.Container`; + * registry image references are the boundary between Docker-managed images and + * cloud container platforms. + * + * @resource + * + * @section Building Images + * @example Build from a Dockerfile + * ```typescript + * const image = yield* Docker.Image("app", { + * name: "my-app", + * tag: "latest", + * build: { + * context: "./app", + * dockerfile: "Dockerfile", + * args: { NODE_ENV: "production" }, + * }, + * }); + * ``` + * + * @section Tagging Remote Images + * @example Pull and tag an image reference + * ```typescript + * const image = yield* Docker.Image("nginx", { + * image: "nginx:alpine", + * tag: "app-base", + * }); + * ``` + * + * @section Registry Push + * @example Push with Redacted credentials + * ```typescript + * const image = yield* Docker.Image("app", { + * name: "my-app", + * build: { context: "./app" }, + * registry: { + * server: "ghcr.io", + * username: "octocat", + * password: Config.redacted("GITHUB_TOKEN"), + * }, + * }); + * ``` + */ +export const Image = Resource("Docker.Image"); + +const imageSourceRef = (source: ImageSource): string => + typeof source === "string" ? source : source.imageRef; + +const imageSourceName = (source: ImageSource): string | undefined => + typeof source === "string" + ? repositoryFromImageRef(source) + : (source.name ?? repositoryFromImageRef(source.imageRef)); + +const isLocalImageSource = (source: ImageSource): boolean => + typeof source !== "string" && source.kind === "Image"; + +const hasBuild = ( + props: ImageProps, +): props is Extract => + "build" in props && props.build !== undefined; + +export const localImageRef = (id: string, props: ImageProps): string => { + const tag = props.tag ?? "latest"; + const name = hasBuild(props) + ? (props.name ?? id) + : (imageSourceName(props.image) ?? id); + return `${name}:${tag}`; +}; + +export const desiredImageRef = (id: string, props: ImageProps): string => { + const ref = localImageRef(id, props); + return props.registry && !props.skipPush + ? withRegistryHost(ref, props.registry) + : ref; +}; + +const buildContext = (build: DockerBuildOptions | undefined) => + build?.context ?? process.cwd(); + +const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = path.resolve(build.context ?? process.cwd()); + const dockerfile = build.dockerfile + ? path.isAbsolute(build.dockerfile) + ? build.dockerfile + : path.resolve(context, build.dockerfile) + : path.resolve(context, "Dockerfile"); + if (!(yield* fs.exists(context))) { + return yield* Effect.die(`Docker build context does not exist: ${context}`); + } + if (!(yield* fs.exists(dockerfile))) { + return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`); + } + return { context, dockerfile }; +}); + +const contextHash = (props: ImageProps) => + hasBuild(props) + ? hashDirectory({ cwd: buildContext(props.build), memo: props.build.memo }) + : Effect.succeed(undefined); + +const comparableProps = (props: ImageProps | undefined) => + props + ? { + ...props, + registry: props.registry + ? { + server: props.registry.server, + username: props.registry.username, + password: props.registry.password, + } + : undefined, + } + : undefined; + +export const ImageProvider = () => + Provider.succeed(Image, { + list: () => Effect.succeed([]), + read: Effect.fnUntraced(function* ({ id, olds, output }) { + const ref = output?.imageRef ?? localImageRef(id, olds); + const image = yield* inspectImage(ref); + if (!image) return undefined; + return { + kind: "Image" as const, + name: output?.name ?? repositoryFromImageRef(ref), + imageRef: ref, + imageId: image.Id, + repoDigest: output?.repoDigest, + tag: output?.tag ?? olds.tag ?? "latest", + builtAt: output?.builtAt ?? Date.now(), + contextHash: output?.contextHash, + }; + }), + diff: Effect.fnUntraced(function* ({ id, news, olds, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + const nextHash = yield* contextHash(news); + if ( + !deepEqual(comparableProps(olds), comparableProps(news)) || + output.imageRef !== desiredImageRef(id, news) || + output.contextHash !== nextHash + ) { + return { action: "update" as const }; + } + }), + reconcile: Effect.fnUntraced(function* ({ id, news, session }) { + const tag = news.tag ?? "latest"; + const ref = localImageRef(id, news); + let currentImageId: string | undefined; + let finalRef = ref; + let repoDigest: string | undefined; + let nextContextHash: string | undefined; + + if (hasBuild(news)) { + const paths = yield* resolveBuildPaths(news.build); + yield* session.note(`Building Docker image: ${ref}`); + yield* buildImage({ + tag: ref, + context: paths.context, + dockerfile: paths.dockerfile, + platform: news.build.platform, + target: news.build.target, + args: news.build.args, + cacheFrom: news.build.cacheFrom, + cacheTo: news.build.cacheTo, + options: news.build.options, + }); + nextContextHash = yield* contextHash(news); + currentImageId = yield* imageId(ref).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ); + } else { + const sourceRef = imageSourceRef(news.image); + if (!isLocalImageSource(news.image)) { + const source = yield* inspectImage(sourceRef); + if (!source) { + yield* session.note(`Pulling Docker image: ${sourceRef}`); + yield* pullImage(sourceRef); + } + } + yield* session.note(`Tagging Docker image: ${sourceRef} -> ${ref}`); + yield* tagImage(sourceRef, ref); + currentImageId = yield* imageId(ref).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ); + } + + if (news.registry && !news.skipPush) { + const pushed = yield* pushImageToRegistry( + ref, + news.registry satisfies RegistryPushCredentials, + ); + finalRef = pushed.imageRef; + repoDigest = pushed.repoDigest; + } + + return { + kind: "Image" as const, + name: repositoryFromImageRef(finalRef), + imageRef: finalRef, + imageId: currentImageId, + repoDigest, + tag, + builtAt: Date.now(), + contextHash: nextContextHash, + }; + }), + delete: Effect.fnUntraced(function* () { + // Docker images are intentionally left in place. Tags and image ids are + // commonly shared by developer workflows outside Alchemy. + }), + }); diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts new file mode 100644 index 0000000000..36f061299d --- /dev/null +++ b/packages/alchemy/src/Docker/Network.ts @@ -0,0 +1,161 @@ +import * as Effect from "effect/Effect"; +import { Unowned } from "../AdoptPolicy.ts"; +import { deepEqual, isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + createNetwork, + inspectNetwork, + removeNetwork, + type NetworkInfo, +} from "./DockerApi.ts"; + +export interface NetworkProps { + /** + * Docker network name. + * + * @default Generated from stack, stage, logical id, and instance id. + */ + name?: string; + /** Network driver. @default "bridge" */ + driver?: "bridge" | "host" | "none" | "overlay" | "macvlan" | (string & {}); + /** Enable IPv6 on the network. @default false */ + enableIPv6?: boolean; + /** Network labels. */ + labels?: Record; +} + +export interface Network extends Resource< + "Docker.Network", + NetworkProps, + { + /** Docker network ID. */ + id: string; + /** Docker network name. */ + name: string; + /** Network driver. */ + driver: string; + /** Whether IPv6 is enabled. */ + enableIPv6: boolean; + /** Labels reported by Docker. */ + labels: Record; + /** Creation timestamp in milliseconds since epoch. */ + createdAt: number; + } +> {} + +/** + * A Docker network managed through the active Docker context. + * + * Existing same-name networks are treated as foreign unless the engine is + * explicitly allowed to adopt them with `--adopt` or `adopt(true)`. + * + * @resource + * + * @section Creating Networks + * @example Basic bridge network + * ```typescript + * const network = yield* Docker.Network("app-network", { + * name: "app-network", + * }); + * ``` + * + * @section Adoption + * @example Adopt a pre-existing network + * ```typescript + * const network = yield* Docker.Network("app-network", { + * name: "shared-app-network", + * }).pipe(adopt(true)); + * ``` + */ +export const Network = Resource("Docker.Network"); + +const networkName = (id: string, props: NetworkProps, instanceId: string) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }); + +export const toNetworkAttributes = ( + info: NetworkInfo, +): Network["Attributes"] => ({ + id: info.Id, + name: info.Name, + driver: info.Driver, + enableIPv6: info.EnableIPv6, + labels: info.Labels ?? {}, + createdAt: Date.parse(info.Created) || Date.now(), +}); + +export const NetworkProvider = () => + Provider.succeed(Network, { + list: () => Effect.succeed([]), + read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { + const name = yield* networkName(id, olds ?? {}, instanceId); + const info = yield* inspectNetwork(name); + if (!info) return undefined; + const attrs = toNetworkAttributes(info); + return output ? attrs : Unowned(attrs); + }), + diff: Effect.fnUntraced(function* ({ news, olds }) { + if (!isResolved(news)) return undefined; + const oldComparable = { + name: olds?.name, + driver: olds?.driver ?? "bridge", + enableIPv6: olds?.enableIPv6 ?? false, + labels: olds?.labels ?? {}, + }; + const newComparable = { + name: news?.name, + driver: news?.driver ?? "bridge", + enableIPv6: news?.enableIPv6 ?? false, + labels: news?.labels ?? {}, + }; + if (!deepEqual(oldComparable, newComparable)) { + return { action: "replace" as const, deleteFirst: true }; + } + }), + reconcile: Effect.fnUntraced(function* ({ + id, + instanceId, + news, + output, + session, + }) { + const name = + output?.name ?? (yield* networkName(id, news ?? {}, instanceId)); + const existing = yield* inspectNetwork(name); + if (existing) { + return toNetworkAttributes(existing); + } + yield* session.note(`Creating Docker network: ${name}`); + const createdId = yield* createNetwork({ + name, + driver: news?.driver ?? "bridge", + enableIPv6: news?.enableIPv6, + labels: news?.labels, + }).pipe( + Effect.catchIf( + (error) => + error.message.includes(`network with name ${name} already exists`), + () => Effect.succeed(undefined), + ), + ); + const info = yield* inspectNetwork(createdId ?? name); + if (!info) { + return yield* Effect.die( + `Docker network could not be inspected: ${name}`, + ); + } + return toNetworkAttributes(info); + }), + delete: Effect.fnUntraced(function* ({ output, session }) { + yield* session.note(`Removing Docker network: ${output.name}`); + yield* removeNetwork(output.id); + }), + }); diff --git a/packages/alchemy/src/Docker/Providers.ts b/packages/alchemy/src/Docker/Providers.ts new file mode 100644 index 0000000000..7cd4e63d47 --- /dev/null +++ b/packages/alchemy/src/Docker/Providers.ts @@ -0,0 +1,35 @@ +import * as Layer from "effect/Layer"; +import * as Provider from "../Provider.ts"; +import { Container, ContainerProvider } from "./Container.ts"; +import { Image, ImageProvider } from "./Image.ts"; +import { Network, NetworkProvider } from "./Network.ts"; +import { RemoteImage, RemoteImageProvider } from "./RemoteImage.ts"; +import { Volume, VolumeProvider } from "./Volume.ts"; + +export class Providers extends Provider.ProviderCollection()( + "Docker", +) {} + +export type ProviderRequirements = Layer.Services>; + +/** + * Registers all Docker resource providers. + * + * Docker providers use the active Docker CLI context and are intentionally + * separate from `Cloudflare.Container`. + */ +export const providers = () => + Layer.effect( + Providers, + Provider.collection([Container, Image, Network, RemoteImage, Volume]), + ).pipe( + Layer.provide( + Layer.mergeAll( + ContainerProvider(), + ImageProvider(), + NetworkProvider(), + RemoteImageProvider(), + VolumeProvider(), + ), + ), + ); diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts new file mode 100644 index 0000000000..44cad5350f --- /dev/null +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -0,0 +1,113 @@ +import * as Effect from "effect/Effect"; +import { deepEqual, isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { imageId, inspectImage, pullImage } from "./DockerApi.ts"; + +export interface RemoteImageProps { + /** Docker image name, without tag. */ + name: string; + /** Docker image tag. @default "latest" */ + tag?: string; + /** Pull for this platform. */ + platform?: string; + /** + * Pull even when an image with the same reference already exists locally. + * + * @default true + */ + alwaysPull?: boolean; +} + +export interface RemoteImage extends Resource< + "Docker.RemoteImage", + RemoteImageProps, + { + kind: "RemoteImage"; + /** Full image reference. */ + imageRef: string; + /** Local image id after pull when available. */ + imageId?: string; + /** Pull timestamp in milliseconds since epoch. */ + createdAt: number; + /** Image name. */ + name: string; + /** Image tag. */ + tag: string; + } +> {} + +/** + * Pulls a remote Docker image through the active Docker context. + * + * The image is available to other Docker resources by `imageRef`. Use + * `alwaysPull: false` when you want to reuse an existing tag in the configured + * Docker daemon instead of pulling on every deploy. + * + * @resource + * + * @section Pulling Images + * @example Pull nginx + * ```typescript + * const nginx = yield* Docker.RemoteImage("nginx", { + * name: "nginx", + * tag: "alpine", + * }); + * ``` + * + * @example Reuse an existing daemon tag + * ```typescript + * const postgres = yield* Docker.RemoteImage("postgres", { + * name: "postgres", + * tag: "18-alpine", + * alwaysPull: false, + * }); + * ``` + */ +export const RemoteImage = Resource("Docker.RemoteImage"); + +export const remoteImageRef = (props: RemoteImageProps): string => + `${props.name}:${props.tag ?? "latest"}`; + +export const RemoteImageProvider = () => + Provider.succeed(RemoteImage, { + list: () => Effect.succeed([]), + read: Effect.fnUntraced(function* ({ olds, output }) { + const ref = output?.imageRef ?? remoteImageRef(olds); + const image = yield* inspectImage(ref); + if (!image) return undefined; + return { + kind: "RemoteImage" as const, + imageRef: ref, + imageId: image.Id, + createdAt: output?.createdAt ?? Date.now(), + name: olds.name, + tag: olds.tag ?? "latest", + }; + }), + diff: Effect.fnUntraced(function* ({ news, olds }) { + if (!isResolved(news)) return undefined; + if (!deepEqual(olds, news) || news.alwaysPull !== false) { + return { action: "update" as const }; + } + }), + reconcile: Effect.fnUntraced(function* ({ news, session }) { + const ref = remoteImageRef(news); + yield* session.note(`Pulling Docker image: ${ref}`); + yield* pullImage(ref, { platform: news.platform }); + return { + kind: "RemoteImage" as const, + imageRef: ref, + imageId: yield* imageId(ref).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ), + createdAt: Date.now(), + name: news.name, + tag: news.tag ?? "latest", + }; + }), + delete: Effect.fnUntraced(function* () { + // Remote images are not removed on destroy because tags may be shared by + // unrelated local stacks or developer workflows. + }), + }); diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts new file mode 100644 index 0000000000..65cdc28ca8 --- /dev/null +++ b/packages/alchemy/src/Docker/Volume.ts @@ -0,0 +1,176 @@ +import * as Effect from "effect/Effect"; +import { Unowned } from "../AdoptPolicy.ts"; +import { deepEqual, isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + createVolume, + inspectVolume, + normalizeLabels, + removeVolume, + type VolumeInfo, +} from "./DockerApi.ts"; + +export interface VolumeLabel { + /** Label name. */ + name: string; + /** Label value. */ + value: string; +} + +export interface VolumeProps { + /** + * Docker volume name. + * + * @default Generated from stack, stage, logical id, and instance id. + */ + name?: string; + /** Volume driver. @default "local" */ + driver?: string; + /** Driver-specific options. */ + driverOpts?: Record; + /** Custom metadata labels. */ + labels?: VolumeLabel[] | Record; +} + +export interface Volume extends Resource< + "Docker.Volume", + VolumeProps, + { + /** Docker volume name. */ + id: string; + /** Docker volume name. */ + name: string; + /** Volume driver. */ + driver: string; + /** Driver-specific options reported by Docker. */ + driverOpts: Record; + /** Labels reported by Docker. */ + labels: Record; + /** Host mountpoint path. */ + mountpoint?: string; + /** Creation timestamp in milliseconds since epoch. */ + createdAt: number; + } +> {} + +/** + * A Docker volume managed through the active Docker context. + * + * Pre-existing same-name volumes are treated as foreign until the engine is + * allowed to adopt them with `--adopt` or `adopt(true)`. + * + * @resource + * + * @section Creating Volumes + * @example Basic volume + * ```typescript + * const data = yield* Docker.Volume("data", { + * name: "app-data", + * }); + * ``` + * + * @example PostgreSQL data volume + * ```typescript + * const data = yield* Docker.Volume("postgres-data"); + * ``` + * + * @example Driver options and labels + * ```typescript + * const data = yield* Docker.Volume("db-data", { + * driver: "local", + * driverOpts: { + * type: "nfs", + * o: "addr=10.0.0.1,rw", + * device: ":/path/to/dir", + * }, + * labels: { + * "com.example.usage": "database", + * }, + * }); + * ``` + */ +export const Volume = Resource("Docker.Volume"); + +const volumeName = (id: string, props: VolumeProps, instanceId: string) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }); + +export const toVolumeAttributes = (info: VolumeInfo): Volume["Attributes"] => ({ + id: info.Name, + name: info.Name, + driver: info.Driver, + driverOpts: info.Options ?? {}, + labels: info.Labels ?? {}, + mountpoint: info.Mountpoint, + createdAt: Date.parse(info.CreatedAt) || Date.now(), +}); + +export const VolumeProvider = () => + Provider.succeed(Volume, { + list: () => Effect.succeed([]), + read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { + const name = yield* volumeName(id, olds ?? {}, instanceId); + const info = yield* inspectVolume(name); + if (!info) return undefined; + const attrs = toVolumeAttributes(info); + return output ? attrs : Unowned(attrs); + }), + diff: Effect.fnUntraced(function* ({ news, olds }) { + if (!isResolved(news)) return undefined; + const oldComparable = { + name: olds?.name, + driver: olds?.driver ?? "local", + driverOpts: olds?.driverOpts ?? {}, + labels: normalizeLabels(olds?.labels), + }; + const newComparable = { + name: news?.name, + driver: news?.driver ?? "local", + driverOpts: news?.driverOpts ?? {}, + labels: normalizeLabels(news?.labels), + }; + if (!deepEqual(oldComparable, newComparable)) { + return { action: "replace" as const, deleteFirst: true }; + } + }), + reconcile: Effect.fnUntraced(function* ({ + id, + instanceId, + news, + output, + session, + }) { + const name = + output?.name ?? (yield* volumeName(id, news ?? {}, instanceId)); + const existing = yield* inspectVolume(name); + if (existing) { + return toVolumeAttributes(existing); + } + yield* session.note(`Creating Docker volume: ${name}`); + const createdName = yield* createVolume({ + name, + driver: news?.driver ?? "local", + driverOpts: news?.driverOpts, + labels: normalizeLabels(news?.labels), + }); + const info = yield* inspectVolume(createdName); + if (!info) { + return yield* Effect.die( + `Docker volume was created but could not be inspected: ${createdName}`, + ); + } + return toVolumeAttributes(info); + }), + delete: Effect.fnUntraced(function* ({ output, session }) { + yield* session.note(`Removing Docker volume: ${output.name}`); + yield* removeVolume(output.name); + }), + }); diff --git a/packages/alchemy/src/Docker/index.ts b/packages/alchemy/src/Docker/index.ts new file mode 100644 index 0000000000..b975b75275 --- /dev/null +++ b/packages/alchemy/src/Docker/index.ts @@ -0,0 +1,7 @@ +export * from "./Container.ts"; +export * from "./DockerApi.ts"; +export * from "./Image.ts"; +export * from "./Network.ts"; +export * from "./Providers.ts"; +export * from "./RemoteImage.ts"; +export * from "./Volume.ts"; diff --git a/packages/alchemy/test/Docker/Docker.integration.test.ts b/packages/alchemy/test/Docker/Docker.integration.test.ts new file mode 100644 index 0000000000..f978f6d175 --- /dev/null +++ b/packages/alchemy/test/Docker/Docker.integration.test.ts @@ -0,0 +1,393 @@ +import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; +import * as Docker from "@/Docker"; +import { inspectContainer, runDockerCommand } from "@/Docker/DockerApi"; +import * as Provider from "@/Provider"; +import { inMemoryState } from "@/State"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:net"; +import { describe } from "vitest"; + +const dockerDaemonOk = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const freeHostPort = Effect.promise( + () => + new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : undefined; + server.close((error) => { + if (error) { + reject(error); + } else if (port) { + resolve(port); + } else { + reject(new Error("Failed to allocate a free host port")); + } + }); + }); + }), +); + +const { test } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), + adopt: true, +}); + +const { test: nonAdoptTest } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), +}); + +const findOwnedError = ( + cause: Cause.Cause, +): OwnedBySomeoneElse | undefined => + cause.reasons + .map((reason) => + Cause.isFailReason(reason) + ? reason.error + : Cause.isDieReason(reason) + ? reason.defect + : undefined, + ) + .find( + (value): value is OwnedBySomeoneElse => + value instanceof OwnedBySomeoneElse, + ); + +test.provider("provider diff canaries for replacements and registry refs", () => + Effect.gen(function* () { + const volumeProvider = yield* Provider.findProvider(Docker.Volume); + const networkProvider = yield* Provider.findProvider(Docker.Network); + const imageProvider = yield* Provider.findProvider(Docker.Image); + + const volumeDiff = yield* volumeProvider.diff!({ + id: "data", + instanceId: "instance", + olds: { name: "data", labels: { usage: "old" } }, + news: { name: "data", labels: { usage: "new" } }, + oldBindings: [], + newBindings: [], + output: undefined, + }); + expect(volumeDiff).toEqual({ action: "replace", deleteFirst: true }); + + const networkDiff = yield* networkProvider.diff!({ + id: "app", + instanceId: "instance", + olds: { name: "app", labels: { usage: "old" } }, + news: { name: "app", labels: { usage: "new" } }, + oldBindings: [], + newBindings: [], + output: undefined, + }); + expect(networkDiff).toEqual({ action: "replace", deleteFirst: true }); + + const imageDiff = yield* imageProvider.diff!({ + id: "app-image", + instanceId: "instance", + olds: { + image: "acme/app:base", + tag: "latest", + registry: { + server: "ghcr.io", + username: "octocat", + password: Redacted.make("token"), + }, + }, + news: { + image: "acme/app:base", + tag: "latest", + registry: { + server: "ghcr.io", + username: "octocat", + password: Redacted.make("token"), + }, + }, + oldBindings: [], + newBindings: [], + output: { + kind: "Image", + name: "ghcr.io/acme/app", + imageRef: "ghcr.io/acme/app:latest", + tag: "latest", + builtAt: Date.now(), + }, + }); + expect(imageDiff).toBeUndefined(); + }), +); + +describe.sequential("Docker resources", () => { + nonAdoptTest.provider.skipIf(!dockerDaemonOk)( + "network refuses pre-existing Docker network unless explicitly adopted", + (stack) => + Effect.gen(function* () { + const networkName = "alchemy-test-network-adoption"; + yield* runDockerCommand(["network", "rm", networkName]).pipe( + Effect.ignore, + ); + yield* runDockerCommand(["network", "create", networkName]); + try { + const error = yield* stack + .deploy( + Effect.gen(function* () { + return yield* Docker.Network("existing-network", { + name: networkName, + }); + }), + ) + .pipe( + Effect.as(undefined), + Effect.catchCause((cause) => + Effect.succeed(findOwnedError(cause)), + ), + ); + expect(error).toBeInstanceOf(OwnedBySomeoneElse); + + const network = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Network("existing-network", { + name: networkName, + }).pipe(adopt(true)); + }), + ); + expect(network.name).toBe(networkName); + expect(network.id.length).toBeGreaterThan(0); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["network", "rm", networkName]).pipe( + Effect.ignore, + ); + } + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "network adopts an existing same-name Docker network with stack adoption", + (stack) => + Effect.gen(function* () { + const networkName = `alchemy-test-network-${Date.now()}`; + yield* runDockerCommand(["network", "create", networkName]); + try { + const network = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Network("existing-network", { + name: networkName, + }); + }), + ); + expect(network.name).toBe(networkName); + expect(network.id.length).toBeGreaterThan(0); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["network", "rm", networkName]).pipe( + Effect.ignore, + ); + } + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "volume adopts an existing Docker volume", + (stack) => + Effect.gen(function* () { + const volumeName = `alchemy-test-volume-${Date.now()}`; + yield* runDockerCommand(["volume", "create", volumeName]); + try { + const volume = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Volume("existing-volume", { + name: volumeName, + }); + }), + ); + expect(volume.name).toBe(volumeName); + expect(volume.id).toBe(volumeName); + expect(volume.driver).toBe("local"); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["volume", "rm", volumeName]).pipe( + Effect.ignore, + ); + } + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "image string source pulls before tagging when the source tag is absent", + (stack) => + Effect.gen(function* () { + const sourceRef = "hello-world:latest"; + const targetTag = "alchemy-test-remote-source"; + const targetRef = `hello-world:${targetTag}`; + yield* runDockerCommand(["rmi", "-f", targetRef, sourceRef]).pipe( + Effect.ignore, + ); + try { + const image = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Image("remote-source-image", { + image: sourceRef, + tag: targetTag, + }); + }), + ); + expect(image.imageRef).toBe(targetRef); + expect(image.imageId?.length).toBeGreaterThan(0); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["rmi", "-f", targetRef, sourceRef]).pipe( + Effect.ignore, + ); + } + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "image builds a tiny Dockerfile", + (stack) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ + prefix: "alchemy-docker-image-", + }); + const imageName = `alchemy-test-image-${Date.now()}`; + try { + yield* fs.writeFileString( + path.join(root, "Dockerfile"), + "FROM scratch\nLABEL alchemy.test=true\n", + ); + const image = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Image("tiny-image", { + name: imageName, + tag: "latest", + build: { context: root }, + }); + }), + ); + expect(image.imageRef).toBe(`${imageName}:latest`); + expect(image.imageId?.length).toBeGreaterThan(0); + expect(image.contextHash?.length).toBeGreaterThan(0); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["rmi", "-f", `${imageName}:latest`]).pipe( + Effect.ignore, + ); + yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore); + } + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "remote image pulls a Docker image reference", + (stack) => + Effect.gen(function* () { + const image = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.RemoteImage("remote-nginx", { + name: "nginx", + tag: "alpine", + alwaysPull: false, + }); + }), + ); + expect(image.imageRef).toBe("nginx:alpine"); + expect(image.imageId?.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "container inspect returns bound host ports", + (stack) => + Effect.gen(function* () { + const containerName = `alchemy-test-container-${Date.now()}`; + const hostPort = yield* freeHostPort; + try { + const container = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Container("nginx-container", { + name: containerName, + image: "nginx:alpine", + ports: [{ external: hostPort, internal: 80 }], + start: true, + }); + }), + ); + expect(container.name).toBe(containerName); + expect(container.state).toBe("running"); + const runtime = yield* Docker.Container.inspect(container); + expect(runtime.ports["80/tcp"]).toBe(hostPort); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["rm", "-f", containerName]).pipe( + Effect.ignore, + ); + } + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "container network aliases update without replacing the container", + (stack) => + Effect.gen(function* () { + const networkName = `alchemy-test-network-alias-${Date.now()}`; + const containerName = `alchemy-test-alias-container-${Date.now()}`; + try { + const deployWithAlias = (alias: string) => + stack.deploy( + Effect.gen(function* () { + yield* Docker.Network("alias-network", { + name: networkName, + }); + return yield* Docker.Container("alias-container", { + name: containerName, + image: "nginx:alpine", + networks: [{ name: networkName, aliases: [alias] }], + }); + }), + ); + + const first = yield* deployWithAlias("old-alias"); + const second = yield* deployWithAlias("new-alias"); + expect(second.id).toBe(first.id); + + const info = yield* inspectContainer(containerName); + const aliases = + info?.NetworkSettings.Networks?.[networkName]?.Aliases ?? []; + expect(aliases).toContain("new-alias"); + expect(aliases).not.toContain("old-alias"); + } finally { + yield* stack.destroy().pipe(Effect.ignore); + yield* runDockerCommand(["rm", "-f", containerName]).pipe( + Effect.ignore, + ); + yield* runDockerCommand(["network", "rm", networkName]).pipe( + Effect.ignore, + ); + } + }), + { timeout: 120000 }, + ); +}); diff --git a/packages/alchemy/test/Docker/DockerApi.test.ts b/packages/alchemy/test/Docker/DockerApi.test.ts new file mode 100644 index 0000000000..44472604be --- /dev/null +++ b/packages/alchemy/test/Docker/DockerApi.test.ts @@ -0,0 +1,241 @@ +import { + buildContainerCreateCommand, + buildImageBuildArgs, + buildNetworkCreateArgs, + buildVolumeCreateArgs, + durationToNanoseconds, + normalizeDuration, + parseRepoDigest, + repositoryFromImageRef, + toRuntimeInfo, + withRegistryHost, +} from "@/Docker/DockerApi"; +import { compareEnv, compareHealthcheck } from "@/Docker/Container"; +import { desiredImageRef, localImageRef } from "@/Docker/Image"; +import { describe, expect, it } from "@effect/vitest"; +import * as Redacted from "effect/Redacted"; + +describe("Docker CLI helpers", () => { + it("normalizes Docker duration values", () => { + expect(normalizeDuration(30)).toBe("30s"); + expect(normalizeDuration("500ms")).toBe("500ms"); + expect(durationToNanoseconds("1.5s")).toBe(1_500_000_000); + expect(durationToNanoseconds("2m")).toBe(120_000_000_000); + const invalidDuration = "30" as unknown as Parameters< + typeof normalizeDuration + >[0]; + expect(() => normalizeDuration(invalidDuration)).toThrow( + /Invalid duration format/, + ); + }); + + it("builds volume create argv", () => { + expect( + buildVolumeCreateArgs({ + name: "data", + driver: "local", + driverOpts: { type: "nfs" }, + labels: { usage: "test" }, + }), + ).toEqual([ + "volume", + "create", + "--name", + "data", + "--driver", + "local", + "--opt", + "type=nfs", + "--label", + "usage=test", + ]); + }); + + it("builds network create argv", () => { + expect( + buildNetworkCreateArgs({ + name: "app", + driver: "bridge", + enableIPv6: true, + labels: { app: "alchemy" }, + }), + ).toEqual([ + "network", + "create", + "--driver", + "bridge", + "--ipv6", + "--label", + "app=alchemy", + "app", + ]); + }); + + it("builds image build argv with v1 options", () => { + expect( + buildImageBuildArgs({ + tag: "app:latest", + context: "/tmp/app", + dockerfile: "/tmp/app/Dockerfile", + platform: "linux/amd64", + target: "runner", + args: { NODE_ENV: "production" }, + cacheFrom: ["type=registry,ref=example/app:cache"], + cacheTo: ["type=inline"], + options: ["--pull"], + }), + ).toEqual([ + "build", + "-t", + "app:latest", + "--platform", + "linux/amd64", + "--target", + "runner", + "--cache-from", + "type=registry,ref=example/app:cache", + "--cache-to", + "type=inline", + "--build-arg", + "NODE_ENV=production", + "--pull", + "-f", + "/tmp/app/Dockerfile", + "/tmp/app", + ]); + }); + + it("keeps Redacted container env values out of argv", () => { + const command = buildContainerCreateCommand({ + image: "postgres:16", + name: "db", + environment: { + POSTGRES_PASSWORD: Redacted.make("super-secret"), + }, + ports: [{ external: 5432, internal: 5432 }], + volumes: [ + { hostPath: "db-data", containerPath: "/var/lib/postgresql/data" }, + ], + restart: "unless-stopped", + healthcheck: { + cmd: "pg_isready", + interval: "5s", + }, + }); + + expect(command.args).toContain("--env"); + expect(command.args).toContain("POSTGRES_PASSWORD"); + expect(command.args.join(" ")).not.toContain("super-secret"); + expect(command.env?.POSTGRES_PASSWORD).toBe("super-secret"); + }); + + it("parses runtime port mappings from NetworkSettings and HostConfig fallback", () => { + expect( + toRuntimeInfo({ + Id: "abc", + Created: new Date().toISOString(), + State: { Status: "running" }, + Config: { Image: "nginx", Cmd: null, Env: null }, + HostConfig: { + Binds: null, + RestartPolicy: { Name: "no", MaximumRetryCount: 0 }, + AutoRemove: false, + PortBindings: { + "81/tcp": [{ HostIp: "0.0.0.0", HostPort: "8081" }], + }, + }, + NetworkSettings: { + Networks: {}, + Ports: { + "80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }], + }, + }, + }), + ).toEqual({ ports: { "80/tcp": 8080, "81/tcp": 8081 } }); + }); + + it("compares Redacted env and healthcheck values", () => { + expect( + compareEnv({ TOKEN: Redacted.value(Redacted.make("abc")) }, [ + "PATH=/usr/bin", + "TOKEN=abc", + ]), + ).toBe(true); + expect( + compareHealthcheck( + { cmd: "curl -f http://localhost/", interval: "5s" }, + { + Test: ["CMD-SHELL", "curl -f http://localhost/"], + Interval: 5_000_000_000, + }, + ), + ).toBe(true); + expect( + compareHealthcheck(undefined, { + Test: ["CMD-SHELL", "curl -f http://localhost/"], + Interval: 5_000_000_000, + }), + ).toBe(true); + expect(compareHealthcheck({ cmd: "true" }, undefined)).toBe(false); + }); + + it("normalizes registry push refs and parses repo digest", () => { + expect(withRegistryHost("app:latest", { server: "ghcr.io" })).toBe( + "ghcr.io/app:latest", + ); + expect( + withRegistryHost("localhost:5000/app:latest", { server: "ghcr.io" }), + ).toBe("localhost:5000/app:latest"); + expect( + parseRepoDigest( + "localhost:5000/app:latest", + "latest: digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa size: 123", + ), + ).toBe( + "localhost:5000/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + }); + + it("parses image repositories without confusing registry ports for tags", () => { + expect(repositoryFromImageRef("nginx:alpine")).toBe("nginx"); + expect(repositoryFromImageRef("ghcr.io/acme/app:latest")).toBe( + "ghcr.io/acme/app", + ); + expect(repositoryFromImageRef("localhost:5000/acme/app:latest")).toBe( + "localhost:5000/acme/app", + ); + expect( + repositoryFromImageRef( + "localhost:5000/acme/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ).toBe("localhost:5000/acme/app"); + expect( + localImageRef("retagged", { + image: "localhost:5000/acme/app:old", + tag: "new", + }), + ).toBe("localhost:5000/acme/app:new"); + expect( + desiredImageRef("retagged", { + image: "localhost:5000/acme/app:old", + tag: "new", + registry: { + server: "ghcr.io", + username: "octocat", + password: Redacted.make("token"), + }, + }), + ).toBe("localhost:5000/acme/app:new"); + expect( + desiredImageRef("retagged", { + image: "acme/app:old", + tag: "new", + registry: { + server: "ghcr.io", + username: "octocat", + password: Redacted.make("token"), + }, + }), + ).toBe("ghcr.io/acme/app:new"); + }); +}); From 74708430c8ac37d87ce245c83633a2a968681675 Mon Sep 17 00:00:00 2001 From: Austin Mudd Date: Fri, 19 Jun 2026 08:36:51 -0700 Subject: [PATCH 02/25] fix(docker): refine inspect API and example secret --- examples/docker-postgres/README.md | 4 +-- examples/docker-postgres/alchemy.run.ts | 12 ++++--- packages/alchemy/src/Docker/Container.ts | 33 ++++++++++--------- packages/alchemy/src/Docker/DockerApi.ts | 8 ++--- packages/alchemy/src/Docker/Image.ts | 6 ++-- packages/alchemy/src/Docker/Network.ts | 8 ++--- packages/alchemy/src/Docker/RemoteImage.ts | 4 +-- packages/alchemy/src/Docker/Volume.ts | 8 ++--- packages/alchemy/src/Docker/index.ts | 1 - .../test/Docker/Docker.integration.test.ts | 6 ++-- 10 files changed, 48 insertions(+), 42 deletions(-) diff --git a/examples/docker-postgres/README.md b/examples/docker-postgres/README.md index 21bc107831..5a71456311 100644 --- a/examples/docker-postgres/README.md +++ b/examples/docker-postgres/README.md @@ -6,7 +6,7 @@ This example provisions PostgreSQL 18 Alpine with Docker resources: - `Docker.Network` creates an app network - `Docker.Volume` creates persistent database storage - `Docker.Container` starts PostgreSQL with a redacted password, a network alias, a named volume, and a host port -- `Docker.Container.inspect` returns the bound host port +- `Docker.inspectContainer` returns the bound host port Docker resources use the active Docker CLI context. That can be Docker Desktop, a remote Docker host, an SSH context, or a CI runner daemon. @@ -20,7 +20,7 @@ POSTGRES_PASSWORD=change-me bun run --filter docker-postgres-example deploy bun run --filter docker-postgres-example destroy ``` -If `POSTGRES_PASSWORD` is omitted, the example uses a redacted development default. +If `POSTGRES_PASSWORD` is omitted, the example generates and stores a redacted password with `Alchemy.makeRandom`. The stack publishes PostgreSQL on `localhost:15432`: diff --git a/examples/docker-postgres/alchemy.run.ts b/examples/docker-postgres/alchemy.run.ts index 8d833b5ed6..9e8f84c509 100644 --- a/examples/docker-postgres/alchemy.run.ts +++ b/examples/docker-postgres/alchemy.run.ts @@ -2,7 +2,7 @@ import * as Alchemy from "alchemy"; import * as Docker from "alchemy/Docker"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; -import * as Redacted from "effect/Redacted"; +import * as Option from "effect/Option"; const POSTGRES_PORT = 15432; const POSTGRES_CONTAINER = "alchemy-example-postgres"; @@ -12,9 +12,13 @@ export default Alchemy.Stack( "DockerPostgresExample", { providers: Docker.providers(), state: Alchemy.localState() }, Effect.gen(function* () { - const password = yield* Config.redacted("POSTGRES_PASSWORD").pipe( - Config.withDefault(Redacted.make("alchemy-postgres")), + const configuredPassword = yield* Config.redacted("POSTGRES_PASSWORD").pipe( + Config.option, ); + const password = yield* Option.match(configuredPassword, { + onSome: Effect.succeed, + onNone: () => Alchemy.makeRandom("PostgresPassword", { bytes: 16 }), + }); const image = yield* Docker.RemoteImage("postgres-image", { name: "postgres", @@ -49,7 +53,7 @@ export default Alchemy.Stack( start: true, }); - const runtime = yield* Docker.Container.inspect(POSTGRES_CONTAINER).pipe( + const runtime = yield* Docker.inspectContainer(POSTGRES_CONTAINER).pipe( Effect.catchTag("DockerCommandError", () => Effect.succeed(EMPTY_RUNTIME), ), diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index fb2fb171d1..d572860389 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -11,7 +11,7 @@ import { disconnectNetwork, durationToNanoseconds, DockerCommandError, - inspectContainer, + inspectContainerInfo, normalizeDuration, removeContainer, startContainer, @@ -28,6 +28,7 @@ import { type VolumeMapping, } from "./DockerApi.ts"; +export { DockerCommandError }; export type { ContainerRuntimeInfo, ContainerStatus, @@ -35,6 +36,7 @@ export type { HealthcheckConfig, NetworkMapping, PortMapping, + SecretString, VolumeMapping, }; @@ -86,12 +88,17 @@ export interface Container extends Resource< } > {} -const inspectRuntime = ( - container: string | { name: string }, +/** + * Inspect a Docker container by name and return normalized runtime details. + * + * This is a small public wrapper around Docker's raw inspect output. It returns + * the stable data Alchemy callers typically need, including bound host ports. + */ +export const inspectContainer = ( + name: string, ): Effect.Effect => Effect.gen(function* () { - const name = typeof container === "string" ? container : container.name; - const info = yield* inspectContainer(name); + const info = yield* inspectContainerInfo(name); if (!info) { return yield* Effect.fail( new DockerCommandError({ @@ -152,14 +159,10 @@ const inspectRuntime = ( * networks: [{ name: network.name, aliases: ["postgres"] }], * start: true, * }); - * const runtime = yield* Docker.Container.inspect(postgresName); + * const runtime = yield* Docker.inspectContainer(postgresName); * ``` */ -export const Container = Resource("Docker.Container")({ - inspect: inspectRuntime, -}); - -export const inspect = inspectRuntime; +export const Container = Resource("Docker.Container"); const containerName = (id: string, props: ContainerProps, instanceId: string) => props.name @@ -373,7 +376,7 @@ const reconcileNetworksAndState = Effect.fn(function* ( if (props.start === false && info.State.Status === "running") { yield* stopContainer(info.Id); } - return yield* inspectContainer(name); + return yield* inspectContainerInfo(name); }); const createAndInspect = Effect.fn(function* ( @@ -398,7 +401,7 @@ const createAndInspect = Effect.fn(function* ( if (props.start) { yield* startContainer(id); } - const info = yield* inspectContainer(name); + const info = yield* inspectContainerInfo(name); if (!info) { return yield* Effect.die( `Docker container was created but could not be inspected: ${name}`, @@ -412,7 +415,7 @@ export const ContainerProvider = () => list: () => Effect.succeed([]), read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { const name = yield* containerName(id, olds, instanceId); - const info = yield* inspectContainer(name); + const info = yield* inspectContainerInfo(name); if (!info) return undefined; const attrs = toContainerAttributes(info, imageRefOf(olds.image)); return output ? attrs : Unowned(attrs); @@ -469,7 +472,7 @@ export const ContainerProvider = () => }) { const name = yield* containerName(id, news, instanceId); const imageRef = imageRefOf(news.image); - const live = yield* inspectContainer(name); + const live = yield* inspectContainerInfo(name); if (live && shouldReplaceContainer(imageRef, news, live)) { yield* session.note(`Replacing Docker container: ${name}`); diff --git a/packages/alchemy/src/Docker/DockerApi.ts b/packages/alchemy/src/Docker/DockerApi.ts index d644efe3a6..c4e4c96357 100644 --- a/packages/alchemy/src/Docker/DockerApi.ts +++ b/packages/alchemy/src/Docker/DockerApi.ts @@ -446,22 +446,22 @@ export const inspectJson = ( ), ); -export const inspectVolume = (name: string) => +export const inspectVolumeInfo = (name: string) => inspectJson(["volume", "inspect", name]).pipe( Effect.map((volumes) => volumes?.[0]), ); -export const inspectNetwork = (name: string) => +export const inspectNetworkInfo = (name: string) => inspectJson(["network", "inspect", name]).pipe( Effect.map((networks) => networks?.[0]), ); -export const inspectContainer = (name: string) => +export const inspectContainerInfo = (name: string) => inspectJson(["container", "inspect", name]).pipe( Effect.map((containers) => containers?.[0]), ); -export const inspectImage = (imageRef: string) => +export const inspectImageInfo = (imageRef: string) => inspectJson(["image", "inspect", imageRef]).pipe( Effect.map((images) => images?.[0]), ); diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index c0d43660cc..55235f82e7 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -9,7 +9,7 @@ import { Resource } from "../Resource.ts"; import { buildImage, imageId, - inspectImage, + inspectImageInfo, pullImage, pushImageToRegistry, repositoryFromImageRef, @@ -231,7 +231,7 @@ export const ImageProvider = () => list: () => Effect.succeed([]), read: Effect.fnUntraced(function* ({ id, olds, output }) { const ref = output?.imageRef ?? localImageRef(id, olds); - const image = yield* inspectImage(ref); + const image = yield* inspectImageInfo(ref); if (!image) return undefined; return { kind: "Image" as const, @@ -285,7 +285,7 @@ export const ImageProvider = () => } else { const sourceRef = imageSourceRef(news.image); if (!isLocalImageSource(news.image)) { - const source = yield* inspectImage(sourceRef); + const source = yield* inspectImageInfo(sourceRef); if (!source) { yield* session.note(`Pulling Docker image: ${sourceRef}`); yield* pullImage(sourceRef); diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index 36f061299d..51bbf0b2ae 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -6,7 +6,7 @@ import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { createNetwork, - inspectNetwork, + inspectNetworkInfo, removeNetwork, type NetworkInfo, } from "./DockerApi.ts"; @@ -97,7 +97,7 @@ export const NetworkProvider = () => list: () => Effect.succeed([]), read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { const name = yield* networkName(id, olds ?? {}, instanceId); - const info = yield* inspectNetwork(name); + const info = yield* inspectNetworkInfo(name); if (!info) return undefined; const attrs = toNetworkAttributes(info); return output ? attrs : Unowned(attrs); @@ -129,7 +129,7 @@ export const NetworkProvider = () => }) { const name = output?.name ?? (yield* networkName(id, news ?? {}, instanceId)); - const existing = yield* inspectNetwork(name); + const existing = yield* inspectNetworkInfo(name); if (existing) { return toNetworkAttributes(existing); } @@ -146,7 +146,7 @@ export const NetworkProvider = () => () => Effect.succeed(undefined), ), ); - const info = yield* inspectNetwork(createdId ?? name); + const info = yield* inspectNetworkInfo(createdId ?? name); if (!info) { return yield* Effect.die( `Docker network could not be inspected: ${name}`, diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index 44cad5350f..7165ca3e8c 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import { deepEqual, isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { imageId, inspectImage, pullImage } from "./DockerApi.ts"; +import { imageId, inspectImageInfo, pullImage } from "./DockerApi.ts"; export interface RemoteImageProps { /** Docker image name, without tag. */ @@ -74,7 +74,7 @@ export const RemoteImageProvider = () => list: () => Effect.succeed([]), read: Effect.fnUntraced(function* ({ olds, output }) { const ref = output?.imageRef ?? remoteImageRef(olds); - const image = yield* inspectImage(ref); + const image = yield* inspectImageInfo(ref); if (!image) return undefined; return { kind: "RemoteImage" as const, diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index 65cdc28ca8..4c24bd55c8 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -6,7 +6,7 @@ import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { createVolume, - inspectVolume, + inspectVolumeInfo, normalizeLabels, removeVolume, type VolumeInfo, @@ -118,7 +118,7 @@ export const VolumeProvider = () => list: () => Effect.succeed([]), read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { const name = yield* volumeName(id, olds ?? {}, instanceId); - const info = yield* inspectVolume(name); + const info = yield* inspectVolumeInfo(name); if (!info) return undefined; const attrs = toVolumeAttributes(info); return output ? attrs : Unowned(attrs); @@ -150,7 +150,7 @@ export const VolumeProvider = () => }) { const name = output?.name ?? (yield* volumeName(id, news ?? {}, instanceId)); - const existing = yield* inspectVolume(name); + const existing = yield* inspectVolumeInfo(name); if (existing) { return toVolumeAttributes(existing); } @@ -161,7 +161,7 @@ export const VolumeProvider = () => driverOpts: news?.driverOpts, labels: normalizeLabels(news?.labels), }); - const info = yield* inspectVolume(createdName); + const info = yield* inspectVolumeInfo(createdName); if (!info) { return yield* Effect.die( `Docker volume was created but could not be inspected: ${createdName}`, diff --git a/packages/alchemy/src/Docker/index.ts b/packages/alchemy/src/Docker/index.ts index b975b75275..b880b0a03a 100644 --- a/packages/alchemy/src/Docker/index.ts +++ b/packages/alchemy/src/Docker/index.ts @@ -1,5 +1,4 @@ export * from "./Container.ts"; -export * from "./DockerApi.ts"; export * from "./Image.ts"; export * from "./Network.ts"; export * from "./Providers.ts"; diff --git a/packages/alchemy/test/Docker/Docker.integration.test.ts b/packages/alchemy/test/Docker/Docker.integration.test.ts index f978f6d175..c2931f561e 100644 --- a/packages/alchemy/test/Docker/Docker.integration.test.ts +++ b/packages/alchemy/test/Docker/Docker.integration.test.ts @@ -1,6 +1,6 @@ import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; import * as Docker from "@/Docker"; -import { inspectContainer, runDockerCommand } from "@/Docker/DockerApi"; +import { inspectContainerInfo, runDockerCommand } from "@/Docker/DockerApi"; import * as Provider from "@/Provider"; import { inMemoryState } from "@/State"; import * as Test from "@/Test/Vitest"; @@ -336,7 +336,7 @@ describe.sequential("Docker resources", () => { ); expect(container.name).toBe(containerName); expect(container.state).toBe("running"); - const runtime = yield* Docker.Container.inspect(container); + const runtime = yield* Docker.inspectContainer(container.name); expect(runtime.ports["80/tcp"]).toBe(hostPort); } finally { yield* stack.destroy().pipe(Effect.ignore); @@ -373,7 +373,7 @@ describe.sequential("Docker resources", () => { const second = yield* deployWithAlias("new-alias"); expect(second.id).toBe(first.id); - const info = yield* inspectContainer(containerName); + const info = yield* inspectContainerInfo(containerName); const aliases = info?.NetworkSettings.Networks?.[networkName]?.Aliases ?? []; expect(aliases).toContain("new-alias"); From 41fce135194e252e4b954dd1352e75a532a66f01 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Thu, 25 Jun 2026 00:10:22 -0700 Subject: [PATCH 03/25] fix(docker): align with main after merge - repoint Image.ts import from ../Build/Memo.ts to ../Command/Memo.ts (Build/ was renamed to Command/ on main) - migrate provider lifecycle ops from Effect.fnUntraced to Effect.fn to match the repo-wide stack-trace migration (b9676c58) - register docker-postgres-example workspace in bun.lock Co-Authored-By: Claude Opus 4.8 --- bun.lock | 10 ++++++++++ packages/alchemy/src/Docker/Container.ts | 14 ++++---------- packages/alchemy/src/Docker/Image.ts | 10 +++++----- packages/alchemy/src/Docker/Network.ts | 14 ++++---------- packages/alchemy/src/Docker/RemoteImage.ts | 8 ++++---- packages/alchemy/src/Docker/Volume.ts | 14 ++++---------- 6 files changed, 31 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 41ebc374c7..b6412fdd28 100644 --- a/bun.lock +++ b/bun.lock @@ -720,6 +720,14 @@ "effect": "catalog:", }, }, + "examples/docker-postgres": { + "name": "docker-postgres-example", + "version": "0.0.0", + "dependencies": { + "alchemy": "workspace:*", + "effect": "catalog:", + }, + }, "examples/monorepo-multi-stack/backend": { "name": "@monorepo-multi-stack/backend", "version": "0.0.0", @@ -2766,6 +2774,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "docker-postgres-example": ["docker-postgres-example@workspace:examples/docker-postgres"], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index d572860389..cd066abec6 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -413,14 +413,14 @@ const createAndInspect = Effect.fn(function* ( export const ContainerProvider = () => Provider.succeed(Container, { list: () => Effect.succeed([]), - read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { + read: Effect.fn(function* ({ id, instanceId, olds, output }) { const name = yield* containerName(id, olds, instanceId); const info = yield* inspectContainerInfo(name); if (!info) return undefined; const attrs = toContainerAttributes(info, imageRefOf(olds.image)); return output ? attrs : Unowned(attrs); }), - diff: Effect.fnUntraced(function* ({ news, olds }) { + diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return undefined; const replaceShape = (props: ContainerProps) => ({ name: props.name, @@ -463,13 +463,7 @@ export const ContainerProvider = () => return { action: "update" as const }; } }), - reconcile: Effect.fnUntraced(function* ({ - id, - instanceId, - news, - output, - session, - }) { + reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { const name = yield* containerName(id, news, instanceId); const imageRef = imageRefOf(news.image); const live = yield* inspectContainerInfo(name); @@ -495,7 +489,7 @@ export const ContainerProvider = () => const created = yield* createAndInspect(name, imageRef, news); return toContainerAttributes(created, imageRef); }), - delete: Effect.fnUntraced(function* ({ output, session }) { + delete: Effect.fn(function* ({ output, session }) { yield* session.note(`Removing Docker container: ${output.name}`); yield* stopContainer(output.name); yield* removeContainer(output.name, true); diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 55235f82e7..2efa9a659c 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Redacted from "effect/Redacted"; -import { hashDirectory, type MemoOptions } from "../Build/Memo.ts"; +import { hashDirectory, type MemoOptions } from "../Command/Memo.ts"; import { deepEqual, isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; @@ -229,7 +229,7 @@ const comparableProps = (props: ImageProps | undefined) => export const ImageProvider = () => Provider.succeed(Image, { list: () => Effect.succeed([]), - read: Effect.fnUntraced(function* ({ id, olds, output }) { + read: Effect.fn(function* ({ id, olds, output }) { const ref = output?.imageRef ?? localImageRef(id, olds); const image = yield* inspectImageInfo(ref); if (!image) return undefined; @@ -244,7 +244,7 @@ export const ImageProvider = () => contextHash: output?.contextHash, }; }), - diff: Effect.fnUntraced(function* ({ id, news, olds, output }) { + diff: Effect.fn(function* ({ id, news, olds, output }) { if (!isResolved(news)) return undefined; if (!output) return undefined; const nextHash = yield* contextHash(news); @@ -256,7 +256,7 @@ export const ImageProvider = () => return { action: "update" as const }; } }), - reconcile: Effect.fnUntraced(function* ({ id, news, session }) { + reconcile: Effect.fn(function* ({ id, news, session }) { const tag = news.tag ?? "latest"; const ref = localImageRef(id, news); let currentImageId: string | undefined; @@ -318,7 +318,7 @@ export const ImageProvider = () => contextHash: nextContextHash, }; }), - delete: Effect.fnUntraced(function* () { + delete: Effect.fn(function* () { // Docker images are intentionally left in place. Tags and image ids are // commonly shared by developer workflows outside Alchemy. }), diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index 51bbf0b2ae..3191a74540 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -95,14 +95,14 @@ export const toNetworkAttributes = ( export const NetworkProvider = () => Provider.succeed(Network, { list: () => Effect.succeed([]), - read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { + read: Effect.fn(function* ({ id, instanceId, olds, output }) { const name = yield* networkName(id, olds ?? {}, instanceId); const info = yield* inspectNetworkInfo(name); if (!info) return undefined; const attrs = toNetworkAttributes(info); return output ? attrs : Unowned(attrs); }), - diff: Effect.fnUntraced(function* ({ news, olds }) { + diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return undefined; const oldComparable = { name: olds?.name, @@ -120,13 +120,7 @@ export const NetworkProvider = () => return { action: "replace" as const, deleteFirst: true }; } }), - reconcile: Effect.fnUntraced(function* ({ - id, - instanceId, - news, - output, - session, - }) { + reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { const name = output?.name ?? (yield* networkName(id, news ?? {}, instanceId)); const existing = yield* inspectNetworkInfo(name); @@ -154,7 +148,7 @@ export const NetworkProvider = () => } return toNetworkAttributes(info); }), - delete: Effect.fnUntraced(function* ({ output, session }) { + delete: Effect.fn(function* ({ output, session }) { yield* session.note(`Removing Docker network: ${output.name}`); yield* removeNetwork(output.id); }), diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index 7165ca3e8c..c20a4304c0 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -72,7 +72,7 @@ export const remoteImageRef = (props: RemoteImageProps): string => export const RemoteImageProvider = () => Provider.succeed(RemoteImage, { list: () => Effect.succeed([]), - read: Effect.fnUntraced(function* ({ olds, output }) { + read: Effect.fn(function* ({ olds, output }) { const ref = output?.imageRef ?? remoteImageRef(olds); const image = yield* inspectImageInfo(ref); if (!image) return undefined; @@ -85,13 +85,13 @@ export const RemoteImageProvider = () => tag: olds.tag ?? "latest", }; }), - diff: Effect.fnUntraced(function* ({ news, olds }) { + diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return undefined; if (!deepEqual(olds, news) || news.alwaysPull !== false) { return { action: "update" as const }; } }), - reconcile: Effect.fnUntraced(function* ({ news, session }) { + reconcile: Effect.fn(function* ({ news, session }) { const ref = remoteImageRef(news); yield* session.note(`Pulling Docker image: ${ref}`); yield* pullImage(ref, { platform: news.platform }); @@ -106,7 +106,7 @@ export const RemoteImageProvider = () => tag: news.tag ?? "latest", }; }), - delete: Effect.fnUntraced(function* () { + delete: Effect.fn(function* () { // Remote images are not removed on destroy because tags may be shared by // unrelated local stacks or developer workflows. }), diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index 4c24bd55c8..24f62cbd52 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -116,14 +116,14 @@ export const toVolumeAttributes = (info: VolumeInfo): Volume["Attributes"] => ({ export const VolumeProvider = () => Provider.succeed(Volume, { list: () => Effect.succeed([]), - read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) { + read: Effect.fn(function* ({ id, instanceId, olds, output }) { const name = yield* volumeName(id, olds ?? {}, instanceId); const info = yield* inspectVolumeInfo(name); if (!info) return undefined; const attrs = toVolumeAttributes(info); return output ? attrs : Unowned(attrs); }), - diff: Effect.fnUntraced(function* ({ news, olds }) { + diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return undefined; const oldComparable = { name: olds?.name, @@ -141,13 +141,7 @@ export const VolumeProvider = () => return { action: "replace" as const, deleteFirst: true }; } }), - reconcile: Effect.fnUntraced(function* ({ - id, - instanceId, - news, - output, - session, - }) { + reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { const name = output?.name ?? (yield* volumeName(id, news ?? {}, instanceId)); const existing = yield* inspectVolumeInfo(name); @@ -169,7 +163,7 @@ export const VolumeProvider = () => } return toVolumeAttributes(info); }), - delete: Effect.fnUntraced(function* ({ output, session }) { + delete: Effect.fn(function* ({ output, session }) { yield* session.note(`Removing Docker volume: ${output.name}`); yield* removeVolume(output.name); }), From 60c1eb203a26fef22daf76dfa6cb0e9b59436867 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Thu, 25 Jun 2026 02:11:39 -0700 Subject: [PATCH 04/25] refactor(docker): auto physical names + drop Date.now() from images - Image now auto-generates its repository name via createPhysicalName (stack/stage/logical-id/instance-id) when no name is given, matching Container/Volume/Network. The resolved name is carried on props.name so the sync ref helpers stay deterministic across reconcile/diff/read. - Source Image.builtAt / RemoteImage.createdAt from Docker's reported `Created` timestamp (new imageCreatedAt helper) instead of Date.now(); reconcile inspects the built/tagged image for both id and creation time. - Wrap process.cwd() in Effect.sync. - Tests rely on engine-generated physical names instead of Date.now() names; adoption tests use deterministic constants with rm-before. Co-Authored-By: Claude Opus 4.8 --- packages/alchemy/src/Docker/DockerApi.ts | 9 ++ packages/alchemy/src/Docker/Image.ts | 99 ++++++++++++------- packages/alchemy/src/Docker/RemoteImage.ts | 11 +-- .../test/Docker/Docker.integration.test.ts | 83 ++++++++++------ 4 files changed, 127 insertions(+), 75 deletions(-) diff --git a/packages/alchemy/src/Docker/DockerApi.ts b/packages/alchemy/src/Docker/DockerApi.ts index c4e4c96357..ff38fa386c 100644 --- a/packages/alchemy/src/Docker/DockerApi.ts +++ b/packages/alchemy/src/Docker/DockerApi.ts @@ -98,6 +98,7 @@ export interface ContainerInfo { export interface ImageInspectInfo { Id: string; + Created?: string; RepoTags?: string[] | null; RepoDigests?: string[] | null; } @@ -466,6 +467,14 @@ export const inspectImageInfo = (imageRef: string) => Effect.map((images) => images?.[0]), ); +/** + * Milliseconds-since-epoch creation time reported by Docker for an inspected + * image. Returns `0` when Docker does not report a parseable timestamp so the + * value stays deterministic (no wall-clock fallback). + */ +export const imageCreatedAt = (info: ImageInspectInfo | undefined): number => + Date.parse(info?.Created ?? "") || 0; + export const createVolume = Effect.fn(function* (input: { name: string; driver?: string; diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 2efa9a659c..e302dce2b1 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -4,11 +4,12 @@ import * as Path from "effect/Path"; import * as Redacted from "effect/Redacted"; import { hashDirectory, type MemoOptions } from "../Command/Memo.ts"; import { deepEqual, isResolved } from "../Diff.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { buildImage, - imageId, + imageCreatedAt, inspectImageInfo, pullImage, pushImageToRegistry, @@ -186,13 +187,31 @@ export const desiredImageRef = (id: string, props: ImageProps): string => { : ref; }; -const buildContext = (build: DockerBuildOptions | undefined) => - build?.context ?? process.cwd(); +/** + * Resolves the built image's repository name. When a build has no explicit + * `name`, an engine physical name is generated (stack + stage + logical id + + * instance id) just like other resources, then carried back on `props.name` so + * the synchronous ref helpers stay deterministic across reconcile/diff/read. + */ +const withResolvedName = ( + id: string, + props: ImageProps, + instanceId: string, +): Effect.Effect => + hasBuild(props) && props.name === undefined + ? createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }).pipe(Effect.map((name): ImageProps => ({ ...props, name }))) + : Effect.succeed(props); const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const context = path.resolve(build.context ?? process.cwd()); + const cwd = yield* Effect.sync(() => process.cwd()); + const context = path.resolve(build.context ?? cwd); const dockerfile = build.dockerfile ? path.isAbsolute(build.dockerfile) ? build.dockerfile @@ -207,10 +226,14 @@ const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) { return { context, dockerfile }; }); -const contextHash = (props: ImageProps) => - hasBuild(props) - ? hashDirectory({ cwd: buildContext(props.build), memo: props.build.memo }) - : Effect.succeed(undefined); +const contextHash = Effect.fn(function* (props: ImageProps) { + if (!hasBuild(props)) return undefined; + const cwd = yield* Effect.sync(() => process.cwd()); + return yield* hashDirectory({ + cwd: props.build.context ?? cwd, + memo: props.build.memo, + }); +}); const comparableProps = (props: ImageProps | undefined) => props @@ -229,8 +252,9 @@ const comparableProps = (props: ImageProps | undefined) => export const ImageProvider = () => Provider.succeed(Image, { list: () => Effect.succeed([]), - read: Effect.fn(function* ({ id, olds, output }) { - const ref = output?.imageRef ?? localImageRef(id, olds); + read: Effect.fn(function* ({ id, instanceId, olds, output }) { + const props = yield* withResolvedName(id, olds, instanceId); + const ref = output?.imageRef ?? localImageRef(id, props); const image = yield* inspectImageInfo(ref); if (!image) return undefined; return { @@ -240,51 +264,49 @@ export const ImageProvider = () => imageId: image.Id, repoDigest: output?.repoDigest, tag: output?.tag ?? olds.tag ?? "latest", - builtAt: output?.builtAt ?? Date.now(), + builtAt: output?.builtAt ?? imageCreatedAt(image), contextHash: output?.contextHash, }; }), - diff: Effect.fn(function* ({ id, news, olds, output }) { + diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) { if (!isResolved(news)) return undefined; if (!output) return undefined; + const props = yield* withResolvedName(id, news, instanceId); const nextHash = yield* contextHash(news); if ( !deepEqual(comparableProps(olds), comparableProps(news)) || - output.imageRef !== desiredImageRef(id, news) || + output.imageRef !== desiredImageRef(id, props) || output.contextHash !== nextHash ) { return { action: "update" as const }; } }), - reconcile: Effect.fn(function* ({ id, news, session }) { - const tag = news.tag ?? "latest"; - const ref = localImageRef(id, news); - let currentImageId: string | undefined; + reconcile: Effect.fn(function* ({ id, instanceId, news, session }) { + const props = yield* withResolvedName(id, news, instanceId); + const tag = props.tag ?? "latest"; + const ref = localImageRef(id, props); let finalRef = ref; let repoDigest: string | undefined; let nextContextHash: string | undefined; - if (hasBuild(news)) { - const paths = yield* resolveBuildPaths(news.build); + if (hasBuild(props)) { + const paths = yield* resolveBuildPaths(props.build); yield* session.note(`Building Docker image: ${ref}`); yield* buildImage({ tag: ref, context: paths.context, dockerfile: paths.dockerfile, - platform: news.build.platform, - target: news.build.target, - args: news.build.args, - cacheFrom: news.build.cacheFrom, - cacheTo: news.build.cacheTo, - options: news.build.options, + platform: props.build.platform, + target: props.build.target, + args: props.build.args, + cacheFrom: props.build.cacheFrom, + cacheTo: props.build.cacheTo, + options: props.build.options, }); - nextContextHash = yield* contextHash(news); - currentImageId = yield* imageId(ref).pipe( - Effect.catch(() => Effect.succeed(undefined)), - ); + nextContextHash = yield* contextHash(props); } else { - const sourceRef = imageSourceRef(news.image); - if (!isLocalImageSource(news.image)) { + const sourceRef = imageSourceRef(props.image); + if (!isLocalImageSource(props.image)) { const source = yield* inspectImageInfo(sourceRef); if (!source) { yield* session.note(`Pulling Docker image: ${sourceRef}`); @@ -293,15 +315,18 @@ export const ImageProvider = () => } yield* session.note(`Tagging Docker image: ${sourceRef} -> ${ref}`); yield* tagImage(sourceRef, ref); - currentImageId = yield* imageId(ref).pipe( - Effect.catch(() => Effect.succeed(undefined)), - ); } - if (news.registry && !news.skipPush) { + // Read the freshly built/tagged image's id and creation time straight + // from Docker rather than synthesizing a wall-clock timestamp. + const inspected = yield* inspectImageInfo(ref); + const currentImageId = inspected?.Id; + const builtAt = imageCreatedAt(inspected); + + if (props.registry && !props.skipPush) { const pushed = yield* pushImageToRegistry( ref, - news.registry satisfies RegistryPushCredentials, + props.registry satisfies RegistryPushCredentials, ); finalRef = pushed.imageRef; repoDigest = pushed.repoDigest; @@ -314,7 +339,7 @@ export const ImageProvider = () => imageId: currentImageId, repoDigest, tag, - builtAt: Date.now(), + builtAt, contextHash: nextContextHash, }; }), diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index c20a4304c0..ad71bd511d 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import { deepEqual, isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { imageId, inspectImageInfo, pullImage } from "./DockerApi.ts"; +import { imageCreatedAt, inspectImageInfo, pullImage } from "./DockerApi.ts"; export interface RemoteImageProps { /** Docker image name, without tag. */ @@ -80,7 +80,7 @@ export const RemoteImageProvider = () => kind: "RemoteImage" as const, imageRef: ref, imageId: image.Id, - createdAt: output?.createdAt ?? Date.now(), + createdAt: output?.createdAt ?? imageCreatedAt(image), name: olds.name, tag: olds.tag ?? "latest", }; @@ -95,13 +95,12 @@ export const RemoteImageProvider = () => const ref = remoteImageRef(news); yield* session.note(`Pulling Docker image: ${ref}`); yield* pullImage(ref, { platform: news.platform }); + const inspected = yield* inspectImageInfo(ref); return { kind: "RemoteImage" as const, imageRef: ref, - imageId: yield* imageId(ref).pipe( - Effect.catch(() => Effect.succeed(undefined)), - ), - createdAt: Date.now(), + imageId: inspected?.Id, + createdAt: imageCreatedAt(inspected), name: news.name, tag: news.tag ?? "latest", }; diff --git a/packages/alchemy/test/Docker/Docker.integration.test.ts b/packages/alchemy/test/Docker/Docker.integration.test.ts index c2931f561e..b662506eb2 100644 --- a/packages/alchemy/test/Docker/Docker.integration.test.ts +++ b/packages/alchemy/test/Docker/Docker.integration.test.ts @@ -123,7 +123,7 @@ test.provider("provider diff canaries for replacements and registry refs", () => name: "ghcr.io/acme/app", imageRef: "ghcr.io/acme/app:latest", tag: "latest", - builtAt: Date.now(), + builtAt: 0, }, }); expect(imageDiff).toBeUndefined(); @@ -180,7 +180,10 @@ describe.sequential("Docker resources", () => { "network adopts an existing same-name Docker network with stack adoption", (stack) => Effect.gen(function* () { - const networkName = `alchemy-test-network-${Date.now()}`; + const networkName = "alchemy-test-network-adopt-existing"; + yield* runDockerCommand(["network", "rm", networkName]).pipe( + Effect.ignore, + ); yield* runDockerCommand(["network", "create", networkName]); try { const network = yield* stack.deploy( @@ -206,7 +209,10 @@ describe.sequential("Docker resources", () => { "volume adopts an existing Docker volume", (stack) => Effect.gen(function* () { - const volumeName = `alchemy-test-volume-${Date.now()}`; + const volumeName = "alchemy-test-volume-adopt-existing"; + yield* runDockerCommand(["volume", "rm", volumeName]).pipe( + Effect.ignore, + ); yield* runDockerCommand(["volume", "create", volumeName]); try { const volume = yield* stack.deploy( @@ -269,7 +275,7 @@ describe.sequential("Docker resources", () => { const root = yield* fs.makeTempDirectory({ prefix: "alchemy-docker-image-", }); - const imageName = `alchemy-test-image-${Date.now()}`; + let imageRef: string | undefined; try { yield* fs.writeFileString( path.join(root, "Dockerfile"), @@ -277,21 +283,24 @@ describe.sequential("Docker resources", () => { ); const image = yield* stack.deploy( Effect.gen(function* () { + // No explicit name: the engine auto-generates the physical name. return yield* Docker.Image("tiny-image", { - name: imageName, tag: "latest", build: { context: root }, }); }), ); - expect(image.imageRef).toBe(`${imageName}:latest`); + imageRef = image.imageRef; + expect(image.imageRef.endsWith(":latest")).toBe(true); expect(image.imageId?.length).toBeGreaterThan(0); expect(image.contextHash?.length).toBeGreaterThan(0); } finally { yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["rmi", "-f", `${imageName}:latest`]).pipe( - Effect.ignore, - ); + if (imageRef) { + yield* runDockerCommand(["rmi", "-f", imageRef]).pipe( + Effect.ignore, + ); + } yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore); } }), @@ -321,28 +330,31 @@ describe.sequential("Docker resources", () => { "container inspect returns bound host ports", (stack) => Effect.gen(function* () { - const containerName = `alchemy-test-container-${Date.now()}`; const hostPort = yield* freeHostPort; + let containerName: string | undefined; try { const container = yield* stack.deploy( Effect.gen(function* () { + // No explicit name: rely on the engine-generated physical name. return yield* Docker.Container("nginx-container", { - name: containerName, image: "nginx:alpine", ports: [{ external: hostPort, internal: 80 }], start: true, }); }), ); - expect(container.name).toBe(containerName); + containerName = container.name; + expect(container.name.length).toBeGreaterThan(0); expect(container.state).toBe("running"); const runtime = yield* Docker.inspectContainer(container.name); expect(runtime.ports["80/tcp"]).toBe(hostPort); } finally { yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["rm", "-f", containerName]).pipe( - Effect.ignore, - ); + if (containerName) { + yield* runDockerCommand(["rm", "-f", containerName]).pipe( + Effect.ignore, + ); + } } }), { timeout: 120000 }, @@ -352,40 +364,47 @@ describe.sequential("Docker resources", () => { "container network aliases update without replacing the container", (stack) => Effect.gen(function* () { - const networkName = `alchemy-test-network-alias-${Date.now()}`; - const containerName = `alchemy-test-alias-container-${Date.now()}`; + let containerName: string | undefined; + let networkName: string | undefined; try { + // No explicit names: the engine generates stable physical names that + // stay constant across the two deploys (same instance id). const deployWithAlias = (alias: string) => stack.deploy( Effect.gen(function* () { - yield* Docker.Network("alias-network", { - name: networkName, - }); - return yield* Docker.Container("alias-container", { - name: containerName, + const network = yield* Docker.Network("alias-network"); + const container = yield* Docker.Container("alias-container", { image: "nginx:alpine", - networks: [{ name: networkName, aliases: [alias] }], + networks: [{ name: network.name, aliases: [alias] }], }); + return { container, network }; }), ); const first = yield* deployWithAlias("old-alias"); + containerName = first.container.name; + networkName = first.network.name; const second = yield* deployWithAlias("new-alias"); - expect(second.id).toBe(first.id); + expect(second.container.id).toBe(first.container.id); - const info = yield* inspectContainerInfo(containerName); + const info = yield* inspectContainerInfo(second.container.name); const aliases = - info?.NetworkSettings.Networks?.[networkName]?.Aliases ?? []; + info?.NetworkSettings.Networks?.[second.network.name]?.Aliases ?? + []; expect(aliases).toContain("new-alias"); expect(aliases).not.toContain("old-alias"); } finally { yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["rm", "-f", containerName]).pipe( - Effect.ignore, - ); - yield* runDockerCommand(["network", "rm", networkName]).pipe( - Effect.ignore, - ); + if (containerName) { + yield* runDockerCommand(["rm", "-f", containerName]).pipe( + Effect.ignore, + ); + } + if (networkName) { + yield* runDockerCommand(["network", "rm", networkName]).pipe( + Effect.ignore, + ); + } } }), { timeout: 120000 }, From 0e948ae3360099f54537b284c92ee5bbce4acaa0 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 11:59:49 -0400 Subject: [PATCH 05/25] move helpers to bottom --- packages/alchemy/src/Docker/Container.ts | 174 +++++++++---------- packages/alchemy/src/Docker/Image.ts | 186 ++++++++++----------- packages/alchemy/src/Docker/Network.ts | 42 ++--- packages/alchemy/src/Docker/RemoteImage.ts | 6 +- packages/alchemy/src/Docker/Volume.ts | 40 ++--- 5 files changed, 224 insertions(+), 224 deletions(-) diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index cd066abec6..a2897067ee 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -9,8 +9,8 @@ import { connectNetwork, createContainer, disconnectNetwork, - durationToNanoseconds, DockerCommandError, + durationToNanoseconds, inspectContainerInfo, normalizeDuration, removeContainer, @@ -164,6 +164,92 @@ export const inspectContainer = ( */ export const Container = Resource("Docker.Container"); +export const ContainerProvider = () => + Provider.succeed(Container, { + list: () => Effect.succeed([]), + read: Effect.fn(function* ({ id, instanceId, olds, output }) { + const name = yield* containerName(id, olds, instanceId); + const info = yield* inspectContainerInfo(name); + if (!info) return undefined; + const attrs = toContainerAttributes(info, imageRefOf(olds.image)); + return output ? attrs : Unowned(attrs); + }), + diff: Effect.fn(function* ({ news, olds }) { + if (!isResolved(news)) return undefined; + const replaceShape = (props: ContainerProps) => ({ + name: props.name, + image: imageRefOf(props.image), + command: props.command ?? [], + environment: normalizeEnvironment(props.environment), + ports: props.ports ?? [], + volumes: props.volumes ?? [], + restart: props.restart ?? "no", + removeOnExit: props.removeOnExit ?? false, + healthcheck: props.healthcheck + ? { + ...props.healthcheck, + interval: + props.healthcheck.interval === undefined + ? undefined + : normalizeDuration(props.healthcheck.interval), + timeout: + props.healthcheck.timeout === undefined + ? undefined + : normalizeDuration(props.healthcheck.timeout), + startPeriod: + props.healthcheck.startPeriod === undefined + ? undefined + : normalizeDuration(props.healthcheck.startPeriod), + startInterval: + props.healthcheck.startInterval === undefined + ? undefined + : normalizeDuration(props.healthcheck.startInterval), + } + : undefined, + }); + if (!deepEqual(replaceShape(olds), replaceShape(news))) { + return { action: "replace" as const, deleteFirst: true }; + } + if ( + !deepEqual(olds.networks ?? [], news.networks ?? []) || + (olds.start ?? false) !== (news.start ?? false) + ) { + return { action: "update" as const }; + } + }), + reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { + const name = yield* containerName(id, news, instanceId); + const imageRef = imageRefOf(news.image); + const live = yield* inspectContainerInfo(name); + + if (live && shouldReplaceContainer(imageRef, news, live)) { + yield* session.note(`Replacing Docker container: ${name}`); + yield* removeContainer(name, true); + } else if (live) { + const current = yield* reconcileNetworksAndState(name, news, live); + if (!current) { + return yield* Effect.die( + `Docker container disappeared during reconcile: ${name}`, + ); + } + return toContainerAttributes(current, imageRef); + } + + yield* session.note( + output + ? `Recreating Docker container: ${name}` + : `Creating Docker container: ${name}`, + ); + const created = yield* createAndInspect(name, imageRef, news); + return toContainerAttributes(created, imageRef); + }), + delete: Effect.fn(function* ({ output, session }) { + yield* session.note(`Removing Docker container: ${output.name}`); + yield* stopContainer(output.name); + yield* removeContainer(output.name, true); + }), + }); + const containerName = (id: string, props: ContainerProps, instanceId: string) => props.name ? Effect.succeed(props.name) @@ -409,89 +495,3 @@ const createAndInspect = Effect.fn(function* ( } return info; }); - -export const ContainerProvider = () => - Provider.succeed(Container, { - list: () => Effect.succeed([]), - read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const name = yield* containerName(id, olds, instanceId); - const info = yield* inspectContainerInfo(name); - if (!info) return undefined; - const attrs = toContainerAttributes(info, imageRefOf(olds.image)); - return output ? attrs : Unowned(attrs); - }), - diff: Effect.fn(function* ({ news, olds }) { - if (!isResolved(news)) return undefined; - const replaceShape = (props: ContainerProps) => ({ - name: props.name, - image: imageRefOf(props.image), - command: props.command ?? [], - environment: normalizeEnvironment(props.environment), - ports: props.ports ?? [], - volumes: props.volumes ?? [], - restart: props.restart ?? "no", - removeOnExit: props.removeOnExit ?? false, - healthcheck: props.healthcheck - ? { - ...props.healthcheck, - interval: - props.healthcheck.interval === undefined - ? undefined - : normalizeDuration(props.healthcheck.interval), - timeout: - props.healthcheck.timeout === undefined - ? undefined - : normalizeDuration(props.healthcheck.timeout), - startPeriod: - props.healthcheck.startPeriod === undefined - ? undefined - : normalizeDuration(props.healthcheck.startPeriod), - startInterval: - props.healthcheck.startInterval === undefined - ? undefined - : normalizeDuration(props.healthcheck.startInterval), - } - : undefined, - }); - if (!deepEqual(replaceShape(olds), replaceShape(news))) { - return { action: "replace" as const, deleteFirst: true }; - } - if ( - !deepEqual(olds.networks ?? [], news.networks ?? []) || - (olds.start ?? false) !== (news.start ?? false) - ) { - return { action: "update" as const }; - } - }), - reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { - const name = yield* containerName(id, news, instanceId); - const imageRef = imageRefOf(news.image); - const live = yield* inspectContainerInfo(name); - - if (live && shouldReplaceContainer(imageRef, news, live)) { - yield* session.note(`Replacing Docker container: ${name}`); - yield* removeContainer(name, true); - } else if (live) { - const current = yield* reconcileNetworksAndState(name, news, live); - if (!current) { - return yield* Effect.die( - `Docker container disappeared during reconcile: ${name}`, - ); - } - return toContainerAttributes(current, imageRef); - } - - yield* session.note( - output - ? `Recreating Docker container: ${name}` - : `Creating Docker container: ${name}`, - ); - const created = yield* createAndInspect(name, imageRef, news); - return toContainerAttributes(created, imageRef); - }), - delete: Effect.fn(function* ({ output, session }) { - yield* session.note(`Removing Docker container: ${output.name}`); - yield* stopContainer(output.name); - yield* removeContainer(output.name, true); - }), - }); diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index e302dce2b1..b9389f0c4f 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -156,99 +156,6 @@ export interface Image extends Resource< */ export const Image = Resource("Docker.Image"); -const imageSourceRef = (source: ImageSource): string => - typeof source === "string" ? source : source.imageRef; - -const imageSourceName = (source: ImageSource): string | undefined => - typeof source === "string" - ? repositoryFromImageRef(source) - : (source.name ?? repositoryFromImageRef(source.imageRef)); - -const isLocalImageSource = (source: ImageSource): boolean => - typeof source !== "string" && source.kind === "Image"; - -const hasBuild = ( - props: ImageProps, -): props is Extract => - "build" in props && props.build !== undefined; - -export const localImageRef = (id: string, props: ImageProps): string => { - const tag = props.tag ?? "latest"; - const name = hasBuild(props) - ? (props.name ?? id) - : (imageSourceName(props.image) ?? id); - return `${name}:${tag}`; -}; - -export const desiredImageRef = (id: string, props: ImageProps): string => { - const ref = localImageRef(id, props); - return props.registry && !props.skipPush - ? withRegistryHost(ref, props.registry) - : ref; -}; - -/** - * Resolves the built image's repository name. When a build has no explicit - * `name`, an engine physical name is generated (stack + stage + logical id + - * instance id) just like other resources, then carried back on `props.name` so - * the synchronous ref helpers stay deterministic across reconcile/diff/read. - */ -const withResolvedName = ( - id: string, - props: ImageProps, - instanceId: string, -): Effect.Effect => - hasBuild(props) && props.name === undefined - ? createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }).pipe(Effect.map((name): ImageProps => ({ ...props, name }))) - : Effect.succeed(props); - -const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* Effect.sync(() => process.cwd()); - const context = path.resolve(build.context ?? cwd); - const dockerfile = build.dockerfile - ? path.isAbsolute(build.dockerfile) - ? build.dockerfile - : path.resolve(context, build.dockerfile) - : path.resolve(context, "Dockerfile"); - if (!(yield* fs.exists(context))) { - return yield* Effect.die(`Docker build context does not exist: ${context}`); - } - if (!(yield* fs.exists(dockerfile))) { - return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`); - } - return { context, dockerfile }; -}); - -const contextHash = Effect.fn(function* (props: ImageProps) { - if (!hasBuild(props)) return undefined; - const cwd = yield* Effect.sync(() => process.cwd()); - return yield* hashDirectory({ - cwd: props.build.context ?? cwd, - memo: props.build.memo, - }); -}); - -const comparableProps = (props: ImageProps | undefined) => - props - ? { - ...props, - registry: props.registry - ? { - server: props.registry.server, - username: props.registry.username, - password: props.registry.password, - } - : undefined, - } - : undefined; - export const ImageProvider = () => Provider.succeed(Image, { list: () => Effect.succeed([]), @@ -348,3 +255,96 @@ export const ImageProvider = () => // commonly shared by developer workflows outside Alchemy. }), }); + +const imageSourceRef = (source: ImageSource): string => + typeof source === "string" ? source : source.imageRef; + +const imageSourceName = (source: ImageSource): string | undefined => + typeof source === "string" + ? repositoryFromImageRef(source) + : (source.name ?? repositoryFromImageRef(source.imageRef)); + +const isLocalImageSource = (source: ImageSource): boolean => + typeof source !== "string" && source.kind === "Image"; + +const hasBuild = ( + props: ImageProps, +): props is Extract => + "build" in props && props.build !== undefined; + +export const localImageRef = (id: string, props: ImageProps): string => { + const tag = props.tag ?? "latest"; + const name = hasBuild(props) + ? (props.name ?? id) + : (imageSourceName(props.image) ?? id); + return `${name}:${tag}`; +}; + +export const desiredImageRef = (id: string, props: ImageProps): string => { + const ref = localImageRef(id, props); + return props.registry && !props.skipPush + ? withRegistryHost(ref, props.registry) + : ref; +}; + +/** + * Resolves the built image's repository name. When a build has no explicit + * `name`, an engine physical name is generated (stack + stage + logical id + + * instance id) just like other resources, then carried back on `props.name` so + * the synchronous ref helpers stay deterministic across reconcile/diff/read. + */ +const withResolvedName = ( + id: string, + props: ImageProps, + instanceId: string, +): Effect.Effect => + hasBuild(props) && props.name === undefined + ? createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }).pipe(Effect.map((name): ImageProps => ({ ...props, name }))) + : Effect.succeed(props); + +const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* Effect.sync(() => process.cwd()); + const context = path.resolve(build.context ?? cwd); + const dockerfile = build.dockerfile + ? path.isAbsolute(build.dockerfile) + ? build.dockerfile + : path.resolve(context, build.dockerfile) + : path.resolve(context, "Dockerfile"); + if (!(yield* fs.exists(context))) { + return yield* Effect.die(`Docker build context does not exist: ${context}`); + } + if (!(yield* fs.exists(dockerfile))) { + return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`); + } + return { context, dockerfile }; +}); + +const contextHash = Effect.fn(function* (props: ImageProps) { + if (!hasBuild(props)) return undefined; + const cwd = yield* Effect.sync(() => process.cwd()); + return yield* hashDirectory({ + cwd: props.build.context ?? cwd, + memo: props.build.memo, + }); +}); + +const comparableProps = (props: ImageProps | undefined) => + props + ? { + ...props, + registry: props.registry + ? { + server: props.registry.server, + username: props.registry.username, + password: props.registry.password, + } + : undefined, + } + : undefined; diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index 3191a74540..f9811c9763 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -71,27 +71,6 @@ export interface Network extends Resource< */ export const Network = Resource("Docker.Network"); -const networkName = (id: string, props: NetworkProps, instanceId: string) => - props.name - ? Effect.succeed(props.name) - : createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }); - -export const toNetworkAttributes = ( - info: NetworkInfo, -): Network["Attributes"] => ({ - id: info.Id, - name: info.Name, - driver: info.Driver, - enableIPv6: info.EnableIPv6, - labels: info.Labels ?? {}, - createdAt: Date.parse(info.Created) || Date.now(), -}); - export const NetworkProvider = () => Provider.succeed(Network, { list: () => Effect.succeed([]), @@ -153,3 +132,24 @@ export const NetworkProvider = () => yield* removeNetwork(output.id); }), }); + +const networkName = (id: string, props: NetworkProps, instanceId: string) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }); + +export const toNetworkAttributes = ( + info: NetworkInfo, +): Network["Attributes"] => ({ + id: info.Id, + name: info.Name, + driver: info.Driver, + enableIPv6: info.EnableIPv6, + labels: info.Labels ?? {}, + createdAt: Date.parse(info.Created) || Date.now(), +}); diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index ad71bd511d..cea0d0dc70 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -66,9 +66,6 @@ export interface RemoteImage extends Resource< */ export const RemoteImage = Resource("Docker.RemoteImage"); -export const remoteImageRef = (props: RemoteImageProps): string => - `${props.name}:${props.tag ?? "latest"}`; - export const RemoteImageProvider = () => Provider.succeed(RemoteImage, { list: () => Effect.succeed([]), @@ -110,3 +107,6 @@ export const RemoteImageProvider = () => // unrelated local stacks or developer workflows. }), }); + +export const remoteImageRef = (props: RemoteImageProps): string => + `${props.name}:${props.tag ?? "latest"}`; diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index 24f62cbd52..d202026a29 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -93,26 +93,6 @@ export interface Volume extends Resource< */ export const Volume = Resource("Docker.Volume"); -const volumeName = (id: string, props: VolumeProps, instanceId: string) => - props.name - ? Effect.succeed(props.name) - : createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }); - -export const toVolumeAttributes = (info: VolumeInfo): Volume["Attributes"] => ({ - id: info.Name, - name: info.Name, - driver: info.Driver, - driverOpts: info.Options ?? {}, - labels: info.Labels ?? {}, - mountpoint: info.Mountpoint, - createdAt: Date.parse(info.CreatedAt) || Date.now(), -}); - export const VolumeProvider = () => Provider.succeed(Volume, { list: () => Effect.succeed([]), @@ -168,3 +148,23 @@ export const VolumeProvider = () => yield* removeVolume(output.name); }), }); + +const volumeName = (id: string, props: VolumeProps, instanceId: string) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }); + +export const toVolumeAttributes = (info: VolumeInfo): Volume["Attributes"] => ({ + id: info.Name, + name: info.Name, + driver: info.Driver, + driverOpts: info.Options ?? {}, + labels: info.Labels ?? {}, + mountpoint: info.Mountpoint, + createdAt: Date.parse(info.CreatedAt) || Date.now(), +}); From 3b2f7bd531c303b08863e256028293aff7cc1758 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 17:40:05 -0400 Subject: [PATCH 06/25] refactor --- packages/alchemy/src/Docker/Container.ts | 594 +++++++----------- packages/alchemy/src/Docker/DockerApi.ts | 565 +---------------- packages/alchemy/src/Docker/DockerClient.ts | 468 ++++++++++++++ packages/alchemy/src/Docker/Image.ts | 215 ++++--- packages/alchemy/src/Docker/Network.ts | 156 +++-- packages/alchemy/src/Docker/Providers.ts | 2 + packages/alchemy/src/Docker/RemoteImage.ts | 97 +-- packages/alchemy/src/Docker/Volume.ts | 130 ++-- packages/alchemy/src/Docker/index.ts | 1 + .../test/Docker/Docker.integration.test.ts | 157 ++--- .../alchemy/test/Docker/DockerApi.test.ts | 41 -- 11 files changed, 1126 insertions(+), 1300 deletions(-) create mode 100644 packages/alchemy/src/Docker/DockerClient.ts diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index a2897067ee..8447093a61 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -1,50 +1,18 @@ +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import { identity } from "effect/Function"; import * as Redacted from "effect/Redacted"; import { Unowned } from "../AdoptPolicy.ts"; -import { deepEqual, isResolved } from "../Diff.ts"; +import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { - connectNetwork, - createContainer, - disconnectNetwork, - DockerCommandError, - durationToNanoseconds, - inspectContainerInfo, - normalizeDuration, - removeContainer, - startContainer, - stopContainer, - toRuntimeInfo, - type ContainerInfo, - type ContainerRuntimeInfo, - type ContainerStatus, - type Duration, - type HealthcheckConfig, - type NetworkMapping, - type PortMapping, - type SecretString, - type VolumeMapping, -} from "./DockerApi.ts"; - -export { DockerCommandError }; -export type { - ContainerRuntimeInfo, - ContainerStatus, - Duration, - HealthcheckConfig, - NetworkMapping, - PortMapping, - SecretString, - VolumeMapping, -}; - -export type ContainerImage = string | { imageRef: string }; +import { Docker } from "./DockerClient.ts"; export interface ContainerProps { /** Image reference or Docker image resource. */ - image: ContainerImage; + image: Container.Image; /** * Container name. * @@ -54,21 +22,69 @@ export interface ContainerProps { /** Command to run in the container. */ command?: string[]; /** Container environment variables. Use Redacted for secrets. */ - environment?: Record; + environment?: Record>; /** Host/container port mappings. */ - ports?: PortMapping[]; + ports?: Container.PortMapping[]; /** Volume or bind mounts. */ - volumes?: VolumeMapping[]; + volumes?: Container.VolumeMapping[]; /** Restart policy. */ restart?: "no" | "always" | "on-failure" | "unless-stopped"; /** Networks to connect after create. */ - networks?: NetworkMapping[]; + networks?: Container.NetworkMapping[]; /** Remove the container when it exits. @default false */ removeOnExit?: boolean; /** Start the container after creation/reconciliation. @default false */ start?: boolean; /** Docker healthcheck configuration. */ - healthcheck?: HealthcheckConfig; + healthcheck?: Container.HealthcheckConfig; +} + +export declare namespace Container { + type Image = string | { imageRef: string }; + type Status = + | "created" + | "running" + | "paused" + | "restarting" + | "removing" + | "exited" + | "dead"; + interface PortMapping { + /** External port on the host. */ + external: number | string; + /** Internal port inside the container. */ + internal: number | string; + /** Protocol used for the mapping. @default "tcp" */ + protocol?: "tcp" | "udp"; + } + interface VolumeMapping { + /** Host path or named volume source. */ + hostPath: string; + /** Container path. */ + containerPath: string; + /** Mount read-only. @default false */ + readOnly?: boolean; + } + interface NetworkMapping { + /** Network name or ID. */ + name: string; + /** Network aliases for the container. */ + aliases?: string[]; + } + interface HealthcheckConfig { + /** Command to run for health checks. */ + cmd: string[] | string; + /** Time between checks. */ + interval?: Duration.Input; + /** Maximum time a check may run. */ + timeout?: Duration.Input; + /** Consecutive failures before unhealthy. */ + retries?: number; + /** Startup grace period. */ + startPeriod?: Duration.Input; + /** Check interval during startup. Requires Docker API 1.44+. */ + startInterval?: Duration.Input; + } } export interface Container extends Resource< @@ -80,7 +96,7 @@ export interface Container extends Resource< /** Docker container name. */ name: string; /** Docker container state. */ - state: ContainerStatus; + state: Container.Status; /** Creation timestamp in milliseconds since epoch. */ createdAt: number; /** Image reference used to create the container. */ @@ -88,30 +104,6 @@ export interface Container extends Resource< } > {} -/** - * Inspect a Docker container by name and return normalized runtime details. - * - * This is a small public wrapper around Docker's raw inspect output. It returns - * the stable data Alchemy callers typically need, including bound host ports. - */ -export const inspectContainer = ( - name: string, -): Effect.Effect => - Effect.gen(function* () { - const info = yield* inspectContainerInfo(name); - if (!info) { - return yield* Effect.fail( - new DockerCommandError({ - command: `docker container inspect ${name}`, - stderr: `Docker container not found: ${name}`, - exitCode: 1, - message: `Docker container not found: ${name}`, - }), - ); - } - return toRuntimeInfo(info); - }); - /** * A Docker container managed through the active Docker context. * @@ -165,90 +157,134 @@ export const inspectContainer = ( export const Container = Resource("Docker.Container"); export const ContainerProvider = () => - Provider.succeed(Container, { - list: () => Effect.succeed([]), - read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const name = yield* containerName(id, olds, instanceId); - const info = yield* inspectContainerInfo(name); - if (!info) return undefined; - const attrs = toContainerAttributes(info, imageRefOf(olds.image)); - return output ? attrs : Unowned(attrs); - }), - diff: Effect.fn(function* ({ news, olds }) { - if (!isResolved(news)) return undefined; - const replaceShape = (props: ContainerProps) => ({ - name: props.name, - image: imageRefOf(props.image), - command: props.command ?? [], - environment: normalizeEnvironment(props.environment), - ports: props.ports ?? [], - volumes: props.volumes ?? [], - restart: props.restart ?? "no", - removeOnExit: props.removeOnExit ?? false, - healthcheck: props.healthcheck - ? { - ...props.healthcheck, - interval: - props.healthcheck.interval === undefined - ? undefined - : normalizeDuration(props.healthcheck.interval), - timeout: - props.healthcheck.timeout === undefined - ? undefined - : normalizeDuration(props.healthcheck.timeout), - startPeriod: - props.healthcheck.startPeriod === undefined - ? undefined - : normalizeDuration(props.healthcheck.startPeriod), - startInterval: - props.healthcheck.startInterval === undefined - ? undefined - : normalizeDuration(props.healthcheck.startInterval), - } - : undefined, - }); - if (!deepEqual(replaceShape(olds), replaceShape(news))) { - return { action: "replace" as const, deleteFirst: true }; - } - if ( - !deepEqual(olds.networks ?? [], news.networks ?? []) || - (olds.start ?? false) !== (news.start ?? false) + Provider.effect( + Container, + Effect.gen(function* () { + const docker = yield* Docker; + + const reconcileNetworks = Effect.fn(function* ( + live: Docker.ContainerInfo, + news: ContainerProps, ) { - return { action: "update" as const }; - } - }), - reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { - const name = yield* containerName(id, news, instanceId); - const imageRef = imageRefOf(news.image); - const live = yield* inspectContainerInfo(name); + const connect = new Map(); + const disconnect = new Set(); + const noop = new Set(); + for (const network of news.networks ?? []) { + const entry = live.NetworkSettings.Networks?.[network.name]; + if (!entry) { + connect.set(network.name, network); + } else if ( + !Equal.equals(entry.Aliases ?? [], network.aliases ?? []) + ) { + connect.set(network.name, network); + disconnect.add(network.name); + } else { + noop.add(network.name); + } + } + for (const key of Object.keys(live.NetworkSettings.Networks ?? {})) { + if (!noop.has(key)) { + disconnect.add(key); + } + } + yield* Effect.forEach( + disconnect, + (network) => + docker.network.disconnect({ network, container: live.Id }), + { concurrency: "unbounded" }, + ); + yield* Effect.forEach( + connect.values(), + (network) => + docker.network.connect({ + network: network.name, + container: live.Id, + alias: network.aliases, + }), + { concurrency: "unbounded" }, + ); + }); - if (live && shouldReplaceContainer(imageRef, news, live)) { - yield* session.note(`Replacing Docker container: ${name}`); - yield* removeContainer(name, true); - } else if (live) { - const current = yield* reconcileNetworksAndState(name, news, live); - if (!current) { - return yield* Effect.die( - `Docker container disappeared during reconcile: ${name}`, + return Container.Provider.of({ + list: () => Effect.succeed([]), + read: Effect.fn(function* ({ id, instanceId, olds, output }) { + const name = yield* containerName(id, olds, instanceId); + return yield* docker.container.inspect(name).pipe( + Effect.map((info) => + toContainerAttributes(info, normalizeImageRef(olds.image)), + ), + Effect.map(output ? identity : Unowned), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), ); - } - return toContainerAttributes(current, imageRef); - } + }), + diff: Effect.fn(function* ({ id, instanceId, news, olds }) { + if (!isResolved(news)) return undefined; + const oldArgs = yield* makeCreateArgs(id, olds, instanceId); + const newArgs = yield* makeCreateArgs(id, news, instanceId); + if (!Equal.equals(oldArgs, newArgs)) { + return { action: "replace" as const, deleteFirst: true }; + } + if ( + !Equal.equals(olds.networks ?? [], news.networks ?? []) || + (olds.start ?? false) !== (news.start ?? false) + ) { + return { action: "update" as const }; + } + }), + reconcile: Effect.fn(function* ({ id, instanceId, news }) { + const args = yield* makeCreateArgs(id, news, instanceId); + const live = yield* docker.container + .inspect(args.name) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); - yield* session.note( - output - ? `Recreating Docker container: ${name}` - : `Creating Docker container: ${name}`, - ); - const created = yield* createAndInspect(name, imageRef, news); - return toContainerAttributes(created, imageRef); - }), - delete: Effect.fn(function* ({ output, session }) { - yield* session.note(`Removing Docker container: ${output.name}`); - yield* stopContainer(output.name); - yield* removeContainer(output.name, true); + if (live) { + yield* reconcileNetworks(live, news); + if (news.start && live.State.Status !== "running") { + yield* docker.container.start(live.Id); + } + return yield* docker.container + .inspect(live.Id) + .pipe( + Effect.map((info) => toContainerAttributes(info, args.image)), + ); + } + + const { stdout: containerId } = yield* docker.container.create(args); + yield* Effect.forEach( + news.networks ?? [], + (network) => + docker.network.connect({ + network: network.name, + container: containerId, + alias: network.aliases, + }), + { concurrency: "unbounded" }, + ); + if (news.start) { + yield* docker.container.start(containerId); + } + const info = yield* docker.container.inspect(containerId); + return toContainerAttributes(info, args.image); + }), + delete: Effect.fn(({ output }) => + docker.container.stop(output.name).pipe( + Effect.andThen(docker.container.remove(output.name, true)), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ), + ), + }); }), - }); + ); const containerName = (id: string, props: ContainerProps, instanceId: string) => props.name @@ -260,11 +296,52 @@ const containerName = (id: string, props: ContainerProps, instanceId: string) => lowercase: true, }); -export const imageRefOf = (image: ContainerImage): string => +const normalizeImageRef = (image: Container.Image): string => typeof image === "string" ? image : image.imageRef; +const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) => + containerName(id, news, instanceId).pipe( + Effect.map((name) => ({ + name, + image: normalizeImageRef(news.image), + command: news.command, + env: normalizeEnvironment(news.environment), + volume: news.volumes?.map( + (v) => `${v.hostPath}:${v.containerPath}${v.readOnly ? ":ro" : ""}`, + ), + p: news.ports?.map( + (port) => `${port.external}:${port.internal}/${port.protocol ?? "tcp"}`, + ), + restart: news.restart ?? "no", + rm: news.removeOnExit ?? false, + ...(news.healthcheck + ? { + "health-cmd": Array.isArray(news.healthcheck.cmd) + ? news.healthcheck.cmd.join(" ") + : news.healthcheck.cmd, + "health-interval": normalizeDuration(news.healthcheck.interval), + "health-timeout": normalizeDuration(news.healthcheck.timeout), + "health-retries": news.healthcheck.retries ?? 0, + "health-start-period": normalizeDuration( + news.healthcheck.startPeriod, + ), + "health-start-interval": normalizeDuration( + news.healthcheck.startInterval, + ), + } + : { + "health-cmd": undefined, + "health-interval": undefined, + "health-timeout": undefined, + "health-retries": undefined, + "health-start-period": undefined, + "health-start-interval": undefined, + }), + })), + ); + const toContainerAttributes = ( - info: ContainerInfo, + info: Docker.ContainerInfo, imageRef: string, ): Container["Attributes"] => ({ id: info.Id, @@ -274,224 +351,25 @@ const toContainerAttributes = ( imageRef, }); -const infoName = (info: ContainerInfo) => { +const infoName = (info: Docker.ContainerInfo) => { const name = info.Name; return typeof name === "string" ? name.replace(/^\//, "") : info.Id; }; -export const normalizeEnvironment = ( - environment: Record | undefined, +const normalizeEnvironment = ( + environment: Record> | undefined, ): Record => Object.fromEntries( Object.entries(environment ?? {}).map(([key, value]) => [ key, - typeof value === "string" ? value : Redacted.value(value), + Redacted.isRedacted(value) ? Redacted.value(value) : value, ]), ); -const normalizePortMappings = ( - ports: PortMapping[] | undefined, -): Map => { - const map = new Map(); - for (const port of ports ?? []) { - map.set(`${port.external}`, `${port.internal}/${port.protocol ?? "tcp"}`); - } - return map; +const normalizeDuration = ( + input: Duration.Input | undefined, +): string | undefined => { + if (!input) return undefined; + const duration = Duration.fromInputUnsafe(input); + return Duration.toNanosUnsafe(duration).toString(); }; - -const normalizeVolumeMappings = ( - volumes: VolumeMapping[] | undefined, -): Set => { - const set = new Set(); - for (const volume of volumes ?? []) { - set.add( - `${volume.hostPath}:${volume.containerPath}${volume.readOnly ? ":ro" : ""}`, - ); - } - return set; -}; - -export const compareEnv = ( - desired: Record | undefined, - actual: string[] | null, -): boolean => { - const desiredEntries = Object.entries(desired ?? {}).sort(([a], [b]) => - a.localeCompare(b), - ); - const actualEntries = (actual ?? []) - .flatMap((entry) => { - const index = entry.indexOf("="); - return index === -1 - ? [] - : [[entry.slice(0, index), entry.slice(index + 1)] as const]; - }) - .filter(([key]) => key in (desired ?? {})) - .sort(([a], [b]) => a.localeCompare(b)); - return deepEqual(desiredEntries, actualEntries); -}; - -export const comparePorts = ( - desired: PortMapping[] | undefined, - actual: Record< - string, - Array<{ HostIp: string; HostPort: string }> | null - > | null, -): boolean => { - const desiredMap = normalizePortMappings(desired); - const actualMap = new Map(); - for (const [containerPort, bindings] of Object.entries(actual ?? {})) { - const hostPort = bindings?.[0]?.HostPort; - if (hostPort) actualMap.set(hostPort, containerPort); - } - return deepEqual([...desiredMap.entries()], [...actualMap.entries()]); -}; - -export const compareVolumes = ( - desired: VolumeMapping[] | undefined, - actual: string[] | null, -): boolean => deepEqual([...normalizeVolumeMappings(desired)], actual ?? []); - -export const compareHealthcheck = ( - desired: HealthcheckConfig | undefined, - actual: - | { - Test: string[] | null; - Interval?: number; - Timeout?: number; - Retries?: number; - StartPeriod?: number; - StartInterval?: number; - } - | null - | undefined, -): boolean => { - if (!desired) return true; - if (!actual) return false; - const desiredCommand = Array.isArray(desired.cmd) - ? desired.cmd.join(" ") - : desired.cmd; - const actualCommand = actual.Test ? actual.Test.slice(1).join(" ") : ""; - return ( - desiredCommand === actualCommand && - durationToNanoseconds(desired.interval) === (actual.Interval ?? 0) && - durationToNanoseconds(desired.timeout) === (actual.Timeout ?? 0) && - (desired.retries ?? 0) === (actual.Retries ?? 0) && - durationToNanoseconds(desired.startPeriod) === (actual.StartPeriod ?? 0) && - durationToNanoseconds(desired.startInterval) === (actual.StartInterval ?? 0) - ); -}; - -export const compareRestartPolicy = ( - desired: ContainerProps["restart"], - actual: ContainerInfo["HostConfig"]["RestartPolicy"], -): boolean => (desired ?? "no") === (actual?.Name || "no"); - -export const shouldReplaceContainer = ( - imageRef: string, - props: ContainerProps, - info: ContainerInfo, -): boolean => { - if (info.Config.Image !== imageRef) return true; - const actualCommand = info.Config.Cmd ?? []; - if (props.command && !deepEqual(props.command, actualCommand)) return true; - if (!compareEnv(normalizeEnvironment(props.environment), info.Config.Env)) { - return true; - } - if (!comparePorts(props.ports, info.HostConfig.PortBindings)) return true; - if (!compareVolumes(props.volumes, info.HostConfig.Binds)) return true; - if (!compareHealthcheck(props.healthcheck, info.Config.Healthcheck)) { - return true; - } - if (!compareRestartPolicy(props.restart, info.HostConfig.RestartPolicy)) { - return true; - } - if ((props.removeOnExit ?? false) !== info.HostConfig.AutoRemove) { - return true; - } - return false; -}; - -const networkChanges = ( - props: ContainerProps, - info: ContainerInfo, -): { connect: NetworkMapping[]; disconnect: string[] } => { - const current = info.NetworkSettings.Networks ?? {}; - const currentNames = new Set(Object.keys(current)); - const desired = new Map((props.networks ?? []).map((n) => [n.name, n])); - const connect: NetworkMapping[] = []; - const disconnect: string[] = []; - for (const network of currentNames) { - if (!desired.has(network) && network !== "bridge") { - disconnect.push(network); - } - } - for (const [name, network] of desired) { - if (!currentNames.has(name)) { - connect.push(network); - continue; - } - const desiredAliases = network.aliases ?? []; - const actualAliases = current[name]?.Aliases ?? []; - if ( - name !== "bridge" && - desiredAliases.length > 0 && - !desiredAliases.every((alias) => actualAliases.includes(alias)) - ) { - disconnect.push(name); - connect.push(network); - } - } - return { connect, disconnect }; -}; - -const reconcileNetworksAndState = Effect.fn(function* ( - name: string, - props: ContainerProps, - info: ContainerInfo, -) { - const changes = networkChanges(props, info); - for (const network of changes.disconnect) { - yield* disconnectNetwork(info.Id, network); - } - for (const network of changes.connect) { - yield* connectNetwork(info.Id, network); - } - if (props.start && info.State.Status !== "running") { - yield* startContainer(info.Id); - } - if (props.start === false && info.State.Status === "running") { - yield* stopContainer(info.Id); - } - return yield* inspectContainerInfo(name); -}); - -const createAndInspect = Effect.fn(function* ( - name: string, - imageRef: string, - props: ContainerProps, -) { - const id = yield* createContainer({ - image: imageRef, - name, - command: props.command, - environment: props.environment, - ports: props.ports, - volumes: props.volumes, - restart: props.restart, - removeOnExit: props.removeOnExit, - healthcheck: props.healthcheck, - }); - for (const network of props.networks ?? []) { - yield* connectNetwork(id, network); - } - if (props.start) { - yield* startContainer(id); - } - const info = yield* inspectContainerInfo(name); - if (!info) { - return yield* Effect.die( - `Docker container was created but could not be inspected: ${name}`, - ); - } - return info; -}); diff --git a/packages/alchemy/src/Docker/DockerApi.ts b/packages/alchemy/src/Docker/DockerApi.ts index ff38fa386c..bfaa80919c 100644 --- a/packages/alchemy/src/Docker/DockerApi.ts +++ b/packages/alchemy/src/Docker/DockerApi.ts @@ -1,107 +1,4 @@ -import { - DockerCommandError, - dockerLogin, - dockerTag, - getDockerImageId, - runDockerCommand, - type RegistryAuth, -} from "../Bundle/Docker.ts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Redacted from "effect/Redacted"; - -export { DockerCommandError, runDockerCommand }; - -export type SecretString = string | Redacted.Redacted; - -export type Duration = number | `${number}${"ms" | "s" | "m" | "h"}`; - -export interface DockerCommandSpec { - readonly args: ReadonlyArray; - readonly env?: Record; -} - -export interface VolumeInfo { - CreatedAt: string; - Driver: string; - Labels: Record | null; - Mountpoint: string; - Name: string; - Options: Record | null; - Scope: string; -} - -export interface NetworkInfo { - Name: string; - Id: string; - Created: string; - Scope: string; - Driver: string; - EnableIPv6: boolean; - Labels: Record | null; -} - -export type ContainerStatus = - | "created" - | "running" - | "paused" - | "restarting" - | "removing" - | "exited" - | "dead"; - -export interface ContainerInfo { - Id: string; - Name?: string; - State: { Status: ContainerStatus }; - Created: string; - Config: { - Image: string; - Cmd: string[] | null; - Env: string[] | null; - Healthcheck?: { - Test: string[] | null; - Interval?: number; - Timeout?: number; - Retries?: number; - StartPeriod?: number; - StartInterval?: number; - } | null; - }; - HostConfig: { - PortBindings: Record< - string, - Array<{ HostIp: string; HostPort: string }> | null - > | null; - Binds: string[] | null; - RestartPolicy: { - Name: string; - MaximumRetryCount: number; - }; - AutoRemove: boolean; - }; - NetworkSettings: { - Networks: Record< - string, - { - NetworkID: string; - Aliases: string[] | null; - } - > | null; - Ports?: Record< - string, - Array<{ HostIp: string; HostPort: string }> | null - > | null; - }; -} - -export interface ImageInspectInfo { - Id: string; - Created?: string; - RepoTags?: string[] | null; - RepoDigests?: string[] | null; -} +import type { Docker } from "./DockerClient.ts"; export interface ContainerRuntimeInfo { /** @@ -111,265 +8,9 @@ export interface ContainerRuntimeInfo { ports: Record; } -export interface PortMapping { - /** External port on the host. */ - external: number | string; - /** Internal port inside the container. */ - internal: number | string; - /** Protocol used for the mapping. @default "tcp" */ - protocol?: "tcp" | "udp"; -} - -export interface VolumeMapping { - /** Host path or named volume source. */ - hostPath: string; - /** Container path. */ - containerPath: string; - /** Mount read-only. @default false */ - readOnly?: boolean; -} - -export interface NetworkMapping { - /** Network name or ID. */ - name: string; - /** Network aliases for the container. */ - aliases?: string[]; -} - -export interface HealthcheckConfig { - /** Command to run for health checks. */ - cmd: string[] | string; - /** Time between checks. */ - interval?: Duration; - /** Maximum time a check may run. */ - timeout?: Duration; - /** Consecutive failures before unhealthy. */ - retries?: number; - /** Startup grace period. */ - startPeriod?: Duration; - /** Check interval during startup. Requires Docker API 1.44+. */ - startInterval?: Duration; -} - -export interface ContainerCreateOptions { - image: string; - name: string; - command?: string[]; - environment?: Record; - ports?: PortMapping[]; - volumes?: VolumeMapping[]; - restart?: "no" | "always" | "on-failure" | "unless-stopped"; - removeOnExit?: boolean; - healthcheck?: HealthcheckConfig; -} - -export interface DockerBuildCommandOptions { - tag: string; - context: string; - dockerfile?: string; - platform?: string; - target?: string; - args?: Record; - cacheFrom?: string[]; - cacheTo?: string[]; - options?: string[]; -} - -export interface RegistryPushCredentials { - server: string; - username: string; - password: SecretString; -} - -export const unwrapSecretString = (value: SecretString): string => - typeof value === "string" ? value : Redacted.value(value); - -export function normalizeDuration(duration: Duration): string { - if (typeof duration === "number") { - return `${duration}s`; - } - if (!/^(\d+(?:\.\d+)?)(ms|s|m|h)$/.test(duration)) { - throw new Error( - `Invalid duration format: "${duration}". Expected number followed by ms, s, m, or h.`, - ); - } - return duration; -} - -export function durationToNanoseconds(duration: Duration | undefined): number { - if (duration === undefined) return 0; - const normalized = normalizeDuration(duration); - const match = normalized.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/); - if (!match) return 0; - const value = Number(match[1]); - switch (match[2]) { - case "ms": - return value * 1_000_000; - case "s": - return value * 1_000_000_000; - case "m": - return value * 60 * 1_000_000_000; - case "h": - return value * 60 * 60 * 1_000_000_000; - default: - return 0; - } -} - -export const normalizeLabels = ( - labels: - | Record - | ReadonlyArray<{ name: string; value: string }> - | undefined, -): Record => { - if (!labels) return {}; - const value = labels as - | Record - | ReadonlyArray<{ name: string; value: string }>; - if (Array.isArray(value)) { - return Object.fromEntries(value.map((label) => [label.name, label.value])); - } - return { ...(value as Record) }; -}; - -export const buildVolumeCreateArgs = (input: { - name: string; - driver?: string; - driverOpts?: Record; - labels?: Record; -}): string[] => { - const args = [ - "volume", - "create", - "--name", - input.name, - "--driver", - input.driver ?? "local", - ]; - for (const [key, value] of Object.entries(input.driverOpts ?? {})) { - args.push("--opt", `${key}=${value}`); - } - for (const [key, value] of Object.entries(input.labels ?? {})) { - args.push("--label", `${key}=${value}`); - } - return args; -}; - -export const buildNetworkCreateArgs = (input: { - name: string; - driver?: string; - enableIPv6?: boolean; - labels?: Record; -}): string[] => { - const args = ["network", "create", "--driver", input.driver ?? "bridge"]; - if (input.enableIPv6) { - args.push("--ipv6"); - } - for (const [key, value] of Object.entries(input.labels ?? {})) { - args.push("--label", `${key}=${value}`); - } - args.push(input.name); - return args; -}; - -export const buildImageBuildArgs = ( - options: DockerBuildCommandOptions, -): string[] => { - const args = ["build", "-t", options.tag]; - if (options.platform) args.push("--platform", options.platform); - if (options.target) args.push("--target", options.target); - for (const source of options.cacheFrom ?? []) { - args.push("--cache-from", source); - } - for (const target of options.cacheTo ?? []) { - args.push("--cache-to", target); - } - for (const [key, value] of Object.entries(options.args ?? {})) { - args.push("--build-arg", `${key}=${value}`); - } - if (options.options?.length) { - args.push(...options.options); - } - if (options.dockerfile) { - args.push("-f", options.dockerfile); - } - args.push(options.context); - return args; -}; - -export const buildContainerCreateCommand = ( - options: ContainerCreateOptions, -): DockerCommandSpec => { - const args = ["create", "--name", options.name]; - const env: Record = {}; - - for (const port of options.ports ?? []) { - const protocol = port.protocol ?? "tcp"; - args.push("-p", `${port.external}:${port.internal}/${protocol}`); - } - - for (const [key, value] of Object.entries(options.environment ?? {})) { - // `--env KEY` reads the value from the child process environment, so - // Redacted secrets are never embedded in argv or DockerCommandError.command. - args.push("--env", key); - env[key] = unwrapSecretString(value); - } - - for (const volume of options.volumes ?? []) { - const readOnly = volume.readOnly ? ":ro" : ""; - args.push("-v", `${volume.hostPath}:${volume.containerPath}${readOnly}`); - } - - if (options.restart) { - args.push("--restart", options.restart); - } - if (options.removeOnExit) { - args.push("--rm"); - } - - if (options.healthcheck) { - const healthcheck = options.healthcheck; - args.push( - "--health-cmd", - Array.isArray(healthcheck.cmd) - ? healthcheck.cmd.join(" ") - : healthcheck.cmd, - ); - if (healthcheck.interval !== undefined) { - args.push("--health-interval", normalizeDuration(healthcheck.interval)); - } - if (healthcheck.timeout !== undefined) { - args.push("--health-timeout", normalizeDuration(healthcheck.timeout)); - } - if (healthcheck.retries !== undefined) { - args.push("--health-retries", String(healthcheck.retries)); - } - if (healthcheck.startPeriod !== undefined) { - args.push( - "--health-start-period", - normalizeDuration(healthcheck.startPeriod), - ); - } - if (healthcheck.startInterval !== undefined) { - args.push( - "--health-start-interval", - normalizeDuration(healthcheck.startInterval), - ); - } - } - - args.push(options.image); - if (options.command?.length) { - args.push(...options.command); - } - - return { - args, - env: Object.keys(env).length ? env : undefined, - }; -}; - -export const toRuntimeInfo = (info: ContainerInfo): ContainerRuntimeInfo => { +export const toRuntimeInfo = ( + info: Docker.ContainerInfo, +): ContainerRuntimeInfo => { const ports: Record = {}; for (const [internal, bindings] of Object.entries( @@ -413,7 +54,7 @@ export const repositoryFromImageRef = (imageRef: string): string => { export const withRegistryHost = ( imageRef: string, - registry: Pick, + registry: { server: string }, ): string => { const registryHost = registry.server.replace(/\/$/, ""); const firstSegment = imageRef.split("/")[0]; @@ -424,199 +65,3 @@ export const withRegistryHost = ( firstSegment === "localhost"); return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`; }; - -const catchMissing = ( - effect: Effect.Effect, -): Effect.Effect => - effect.pipe( - Effect.catchIf( - (error) => - /No such|not found|No such object|No such image|No such container/i.test( - error.message, - ), - () => Effect.succeed(undefined), - ), - ); - -export const inspectJson = ( - args: ReadonlyArray, -): Effect.Effect => - catchMissing( - runDockerCommand(args).pipe( - Effect.map(({ stdout }) => JSON.parse(stdout.trim()) as A), - ), - ); - -export const inspectVolumeInfo = (name: string) => - inspectJson(["volume", "inspect", name]).pipe( - Effect.map((volumes) => volumes?.[0]), - ); - -export const inspectNetworkInfo = (name: string) => - inspectJson(["network", "inspect", name]).pipe( - Effect.map((networks) => networks?.[0]), - ); - -export const inspectContainerInfo = (name: string) => - inspectJson(["container", "inspect", name]).pipe( - Effect.map((containers) => containers?.[0]), - ); - -export const inspectImageInfo = (imageRef: string) => - inspectJson(["image", "inspect", imageRef]).pipe( - Effect.map((images) => images?.[0]), - ); - -/** - * Milliseconds-since-epoch creation time reported by Docker for an inspected - * image. Returns `0` when Docker does not report a parseable timestamp so the - * value stays deterministic (no wall-clock fallback). - */ -export const imageCreatedAt = (info: ImageInspectInfo | undefined): number => - Date.parse(info?.Created ?? "") || 0; - -export const createVolume = Effect.fn(function* (input: { - name: string; - driver?: string; - driverOpts?: Record; - labels?: Record; -}) { - const { stdout } = yield* runDockerCommand(buildVolumeCreateArgs(input)); - return stdout.trim(); -}); - -export const createNetwork = Effect.fn(function* (input: { - name: string; - driver?: string; - enableIPv6?: boolean; - labels?: Record; -}) { - const { stdout } = yield* runDockerCommand(buildNetworkCreateArgs(input)); - return stdout.trim(); -}); - -export const removeNetwork = (id: string) => - runDockerCommand(["network", "rm", id]).pipe( - Effect.catchIf( - (error) => /No such network|not found/i.test(error.message), - () => Effect.void, - ), - ); - -export const removeVolume = (name: string) => - runDockerCommand(["volume", "rm", name]).pipe( - Effect.catchIf( - (error) => /No such volume|not found/i.test(error.message), - () => Effect.void, - ), - Effect.asVoid, - ); - -export const removeContainer = (name: string, force = false) => - runDockerCommand(["rm", ...(force ? ["-f"] : []), name]).pipe( - Effect.catchIf( - (error) => /No such container|not found/i.test(error.message), - () => Effect.void, - ), - Effect.asVoid, - ); - -export const stopContainer = (name: string) => - runDockerCommand(["stop", name]).pipe( - Effect.catchIf( - (error) => - /No such container|not running|is not running/i.test(error.message), - () => Effect.void, - ), - Effect.asVoid, - ); - -export const startContainer = (name: string) => - runDockerCommand(["start", name]).pipe(Effect.asVoid); - -export const connectNetwork = (container: string, network: NetworkMapping) => { - const args = ["network", "connect"]; - for (const alias of network.aliases ?? []) { - args.push("--alias", alias); - } - args.push(network.name, container); - return runDockerCommand(args).pipe(Effect.asVoid); -}; - -export const disconnectNetwork = (container: string, network: string) => - runDockerCommand(["network", "disconnect", network, container]).pipe( - Effect.catchIf( - (error) => - /is not connected|No such network|No such container/i.test( - error.message, - ), - () => Effect.void, - ), - Effect.asVoid, - ); - -export const createContainer = Effect.fn(function* ( - options: ContainerCreateOptions, -) { - const command = buildContainerCreateCommand(options); - const { stdout } = yield* runDockerCommand(command.args, { - env: command.env, - }); - return stdout.trim(); -}); - -export const pullImage = (imageRef: string, options?: { platform?: string }) => - runDockerCommand([ - "pull", - ...(options?.platform ? ["--platform", options.platform] : []), - imageRef, - ]).pipe(Effect.asVoid); - -export const tagImage = dockerTag; - -export const buildImage = (options: DockerBuildCommandOptions) => - runDockerCommand(buildImageBuildArgs(options), { - cwd: options.context, - }).pipe(Effect.asVoid); - -export const imageId = getDockerImageId; - -export const pushImageToRegistry = Effect.fn(function* ( - imageRef: string, - credentials: RegistryPushCredentials, -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const registryHost = credentials.server.replace(/\/$/, ""); - const targetImage = withRegistryHost(imageRef, credentials); - const configDir = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-", - }); - const env = { DOCKER_CONFIG: configDir }; - const auth: RegistryAuth = { - server: registryHost, - username: credentials.username, - password: unwrapSecretString(credentials.password), - }; - - return yield* Effect.gen(function* () { - yield* dockerLogin(auth, { env }); - if (targetImage !== imageRef) { - yield* dockerTag(imageRef, targetImage); - } - const { stdout, stderr } = yield* runDockerCommand(["push", targetImage], { - env, - }); - const repoDigest = parseRepoDigest(targetImage, `${stdout}\n${stderr}`); - return { - imageRef: targetImage, - repoDigest, - }; - }).pipe( - Effect.ensuring( - fs - .remove(path.resolve(configDir), { recursive: true }) - .pipe(Effect.catch(() => Effect.void)), - ), - ); -}); diff --git a/packages/alchemy/src/Docker/DockerClient.ts b/packages/alchemy/src/Docker/DockerClient.ts new file mode 100644 index 0000000000..6097bbf3e1 --- /dev/null +++ b/packages/alchemy/src/Docker/DockerClient.ts @@ -0,0 +1,468 @@ +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { + PlatformError, + SystemError, + type SystemErrorTag, +} from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +export class Docker extends Context.Service< + Docker, + { + readonly run: ( + args: Array, + ) => Effect.Effect; + readonly container: { + readonly create: (options: { + name: string; + image: string; + volume: Array | undefined; + env: Record | undefined; + restart: "no" | "always" | "on-failure" | "unless-stopped"; + rm: boolean; + "health-cmd": string | undefined; + "health-interval": string | undefined; + "health-timeout": string | undefined; + "health-retries": number | undefined; + "health-start-period": string | undefined; + "health-start-interval": string | undefined; + p: Array | undefined; + command: Array | undefined; + }) => Effect.Effect; + readonly inspect: ( + name: string, + ) => Effect.Effect; + readonly remove: ( + name: string, + force?: boolean, + ) => Effect.Effect; + readonly start: ( + name: string, + ) => Effect.Effect; + readonly stop: ( + name: string, + ) => Effect.Effect; + }; + readonly image: { + readonly build: (options: { + context: string; + tag: string; + file?: string; + platform?: string; + target?: string; + "build-arg"?: Record; + "cache-from"?: Array; + "cache-to"?: Array; + args?: Array; + }) => Effect.Effect; + readonly pull: ( + ref: string, + platform?: string, + ) => Effect.Effect; + readonly push: ( + ref: string, + credentials: { + server: string; + username: string; + password: string | Redacted.Redacted; + }, + ) => Effect.Effect; + readonly tag: ( + source: string, + target: string, + ) => Effect.Effect; + readonly inspect: ( + ref: string, + ) => Effect.Effect; + readonly remove: ( + ref: string | Array, + force?: boolean, + ) => Effect.Effect; + }; + readonly volume: { + readonly create: (options: { + name: string; + driver?: string; + opt?: Record; + label?: Record; + }) => Effect.Effect; + readonly remove: ( + name: string, + ) => Effect.Effect; + readonly inspect: ( + name: string, + ) => Effect.Effect; + }; + readonly network: { + readonly create: (options: { + name: string; + driver: string; + ipv6?: boolean; + label?: Record; + }) => Effect.Effect; + readonly connect: (options: { + network: string; + container: string; + alias?: string[]; + }) => Effect.Effect; + readonly disconnect: (options: { + network: string; + container: string; + }) => Effect.Effect; + readonly inspect: ( + name: string, + ) => Effect.Effect; + readonly remove: ( + id: string, + ) => Effect.Effect; + }; + } +>()("@alchemy/docker/client") {} + +export declare namespace Docker { + export type ContainerStatus = + | "created" + | "running" + | "paused" + | "restarting" + | "removing" + | "exited" + | "dead"; + + export interface ContainerInfo { + Id: string; + Name?: string; + State: { Status: ContainerStatus }; + Created: string; + Config: { + Image: string; + Cmd: string[] | null; + Env: string[] | null; + Healthcheck?: { + Test: string[] | null; + Interval?: number; + Timeout?: number; + Retries?: number; + StartPeriod?: number; + StartInterval?: number; + } | null; + }; + HostConfig: { + PortBindings: Record< + string, + Array<{ HostIp: string; HostPort: string }> | null + > | null; + Binds: string[] | null; + RestartPolicy: { + Name: string; + MaximumRetryCount: number; + }; + AutoRemove: boolean; + }; + NetworkSettings: { + Networks: Record< + string, + { + NetworkID: string; + Aliases: string[] | null; + } + > | null; + Ports?: Record< + string, + Array<{ HostIp: string; HostPort: string }> | null + > | null; + }; + } + + export interface InspectedImage { + Id: string; + Created?: string; + RepoTags?: string[] | null; + RepoDigests?: string[] | null; + } + export interface InspectedVolume { + CreatedAt: string; + Driver: string; + Labels: Record | null; + Mountpoint: string; + Name: string; + Options: Record | null; + Scope: string; + } + export interface InspectedNetwork { + Name: string; + Id: string; + Created: string; + Scope: string; + Driver: string; + EnableIPv6: boolean; + Labels: Record | null; + } +} + +interface CommandOutput { + exitCode: ChildProcessSpawner.ExitCode; + stdout: string; + stderr: string; +} + +const DockerBin = Config.string("DOCKER_BIN").pipe( + Effect.orElseSucceed(() => "docker"), +); + +export const DockerLive = Layer.effect( + Docker, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const bin = yield* DockerBin; + + const run = (args: Array, env?: Record) => + ChildProcess.make(bin, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: false, + env, + extendEnv: true, + }).pipe( + spawner.spawn, + Effect.flatMap((child) => + Effect.all( + { + exitCode: child.exitCode, + stdout: child.stdout.pipe( + Stream.decodeText, + Stream.tap(Effect.logDebug), + Stream.mkString, + Effect.map((stdout) => stdout.trim()), + ), + stderr: child.stderr.pipe( + Stream.decodeText, + Stream.tap(Effect.logDebug), + Stream.mkString, + Effect.map((stderr) => stderr.trim()), + ), + }, + { concurrency: "unbounded" }, + ), + ), + Effect.mapError((error) => + systemError({ + _tag: "Unknown", + args, + description: "The command failed unexpectedly.", + cause: error.reason, + }), + ), + Effect.tap((result) => { + if (result.exitCode === 0) return Effect.void; + const stderr = result.stderr.replace( + /^Error response from daemon: /, + "", + ); + if (stderr.match(/no such/i) || stderr.match(/not found/i)) { + return systemError({ + _tag: "NotFound", + args, + description: stderr, + }); + } + if (stderr.match(/already exists/i)) { + return systemError({ + _tag: "AlreadyExists", + args, + description: stderr, + }); + } + return systemError({ + _tag: "Unknown", + args, + description: `Command exited with code ${result.exitCode}: ${stderr}`, + }); + }), + Effect.scoped, + ); + + const systemError = (input: { + _tag: SystemErrorTag; + args: Array; + description?: string; + cause?: unknown; + }) => + new PlatformError( + new SystemError({ + _tag: input._tag, + module: "Docker", + method: input.args.slice(0, 2).join("."), + pathOrDescriptor: input.args[2], + description: input.description, + cause: input.cause, + }), + ); + + const runInspect = (args: Array) => + run(args).pipe( + Effect.map((result) => { + const [item] = JSON.parse(result.stdout) as T[]; + return item; + }), + ); + + const argsFrom = ( + options: Record< + string, + | boolean + | string + | number + | undefined + | Record + | Array + >, + ) => { + const args: Array = []; + for (const [key, value] of Object.entries(options)) { + if (!value) continue; + const prefix = key.length > 1 ? `--${key}` : `-${key}`; + if (value === true) { + args.push(prefix); + } else if (typeof value === "string") { + args.push(prefix, value); + } else if (typeof value === "number") { + args.push(prefix, String(value)); + } else if (Array.isArray(value)) { + for (const item of value) { + args.push(prefix, item); + } + } else if (value !== null && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + args.push(prefix, `${k}=${v}`); + } + } + } + return args; + }; + + return Docker.of({ + run, + container: { + create: ({ image, env, ...options }) => + run( + [ + "container", + "create", + ...argsFrom({ + ...options, + env: env ? Object.keys(env) : undefined, + }), + image, + ...(options.command ?? []), + ], + env, + ), + inspect: (name) => + runInspect(["container", "inspect", name]), + remove: (name, force) => + run(["container", "rm", name, ...(force ? ["-f"] : [])]), + start: (name) => run(["container", "start", name]), + stop: (name) => run(["container", "stop", name]), + }, + image: { + build: ({ context, args, ...options }) => + run([ + "image", + "build", + context, + ...argsFrom(options), + ...(args ?? []), + ]), + pull: (ref, platform) => + run([ + "image", + "pull", + ref, + ...(platform ? ["--platform", platform] : []), + ]), + inspect: (ref) => + runInspect(["image", "inspect", ref]), + remove: (ref, force) => + run([ + "image", + "rm", + ...(Array.isArray(ref) ? ref : [ref]), + ...(force ? ["-f"] : []), + ]), + tag: (source, target) => run(["image", "tag", source, target]), + push: Effect.fn(function* (ref, credentials) { + // Write the registry credentials directly into an isolated docker config + // as a plaintext `auths` entry and skip `docker login` entirely. + // + // `docker login` is the wrong tool here: on macOS Docker Desktop it routes + // through the shared `osxkeychain`/`desktop` credential helper *regardless* + // of an isolated DOCKER_CONFIG, so concurrent deploys either race the system + // keychain (`The specified item already exists in the keychain (-25299)`) or + // land the credential in the helper — leaving this isolated config without + // an `auths` entry, so the subsequent `docker push` fails with "no basic + // auth credentials". Embedding the base64 `auth` inline (the same thing + // `docker login` would write when no credsStore is configured) makes each + // deploy fully self-contained: no credential helper, no keychain, no login + // race. Only `push` reads this config; `build`/`pull`/`tag` keep using the + // global docker config (buildx builders, `docker context`, etc. intact). + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-", + }); + yield* fs.writeFileString( + path.join(dir, "config.json"), + JSON.stringify({ + auths: { + [credentials.server]: { + auth: Buffer.from( + `${credentials.username}:${Redacted.isRedacted(credentials.password) ? Redacted.value(credentials.password) : credentials.password}`, + ).toString("base64"), + }, + }, + }), + ); + return yield* run(["push", ref], { DOCKER_CONFIG: dir }); + }, Effect.scoped), + }, + volume: { + create: (options) => run(["volume", "create", ...argsFrom(options)]), + remove: (name) => run(["volume", "rm", name]), + inspect: (name) => + runInspect(["volume", "inspect", name]), + }, + network: { + create: ({ name, driver, ipv6, label }) => + run([ + "network", + "create", + name, + ...argsFrom({ driver, ipv6, label }), + ]), + connect: ({ network, container, alias }) => + run([ + "network", + "connect", + network, + container, + ...(alias ? alias.flatMap((a) => ["--alias", a]) : []), + ]), + disconnect: ({ network, container }) => + run(["network", "disconnect", network, container]), + inspect: (name) => + runInspect(["network", "inspect", name]), + remove: (id) => run(["network", "rm", id]), + }, + }); + }), +); diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index b9389f0c4f..4cdda35e57 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -8,16 +8,11 @@ import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { - buildImage, - imageCreatedAt, - inspectImageInfo, - pullImage, - pushImageToRegistry, + parseRepoDigest, repositoryFromImageRef, - tagImage, withRegistryHost, - type RegistryPushCredentials, } from "./DockerApi.ts"; +import { Docker } from "./DockerClient.ts"; export interface DockerBuildOptions { /** @@ -157,104 +152,126 @@ export interface Image extends Resource< export const Image = Resource("Docker.Image"); export const ImageProvider = () => - Provider.succeed(Image, { - list: () => Effect.succeed([]), - read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const props = yield* withResolvedName(id, olds, instanceId); - const ref = output?.imageRef ?? localImageRef(id, props); - const image = yield* inspectImageInfo(ref); - if (!image) return undefined; - return { - kind: "Image" as const, - name: output?.name ?? repositoryFromImageRef(ref), - imageRef: ref, - imageId: image.Id, - repoDigest: output?.repoDigest, - tag: output?.tag ?? olds.tag ?? "latest", - builtAt: output?.builtAt ?? imageCreatedAt(image), - contextHash: output?.contextHash, - }; - }), - diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) { - if (!isResolved(news)) return undefined; - if (!output) return undefined; - const props = yield* withResolvedName(id, news, instanceId); - const nextHash = yield* contextHash(news); - if ( - !deepEqual(comparableProps(olds), comparableProps(news)) || - output.imageRef !== desiredImageRef(id, props) || - output.contextHash !== nextHash - ) { - return { action: "update" as const }; - } - }), - reconcile: Effect.fn(function* ({ id, instanceId, news, session }) { - const props = yield* withResolvedName(id, news, instanceId); - const tag = props.tag ?? "latest"; - const ref = localImageRef(id, props); - let finalRef = ref; - let repoDigest: string | undefined; - let nextContextHash: string | undefined; + Provider.effect( + Image, + Effect.gen(function* () { + const docker = yield* Docker; - if (hasBuild(props)) { - const paths = yield* resolveBuildPaths(props.build); - yield* session.note(`Building Docker image: ${ref}`); - yield* buildImage({ - tag: ref, - context: paths.context, - dockerfile: paths.dockerfile, - platform: props.build.platform, - target: props.build.target, - args: props.build.args, - cacheFrom: props.build.cacheFrom, - cacheTo: props.build.cacheTo, - options: props.build.options, - }); - nextContextHash = yield* contextHash(props); - } else { - const sourceRef = imageSourceRef(props.image); - if (!isLocalImageSource(props.image)) { - const source = yield* inspectImageInfo(sourceRef); - if (!source) { - yield* session.note(`Pulling Docker image: ${sourceRef}`); - yield* pullImage(sourceRef); + return Image.Provider.of({ + list: () => Effect.succeed([]), + read: Effect.fn(function* ({ id, instanceId, olds, output }) { + const props = yield* withResolvedName(id, olds, instanceId); + const ref = output?.imageRef ?? localImageRef(id, props); + const image = yield* docker.image + .inspect(ref) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + if (!image) return undefined; + return { + kind: "Image" as const, + name: output?.name ?? repositoryFromImageRef(ref), + imageRef: ref, + imageId: image.Id, + repoDigest: output?.repoDigest, + tag: output?.tag ?? olds.tag ?? "latest", + builtAt: output?.builtAt ?? Date.parse(image.Created ?? ""), + contextHash: output?.contextHash, + }; + }), + diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) { + if (!isResolved(news)) return undefined; + if (!output) return undefined; + const props = yield* withResolvedName(id, news, instanceId); + const nextHash = yield* contextHash(news); + if ( + !deepEqual(comparableProps(olds), comparableProps(news)) || + output.imageRef !== desiredImageRef(id, props) || + output.contextHash !== nextHash + ) { + return { action: "update" as const }; } - } - yield* session.note(`Tagging Docker image: ${sourceRef} -> ${ref}`); - yield* tagImage(sourceRef, ref); - } + }), + reconcile: Effect.fn(function* ({ id, instanceId, news, session }) { + const props = yield* withResolvedName(id, news, instanceId); + const tag = props.tag ?? "latest"; + const ref = localImageRef(id, props); + let finalRef = ref; + let repoDigest: string | undefined; + let nextContextHash: string | undefined; - // Read the freshly built/tagged image's id and creation time straight - // from Docker rather than synthesizing a wall-clock timestamp. - const inspected = yield* inspectImageInfo(ref); - const currentImageId = inspected?.Id; - const builtAt = imageCreatedAt(inspected); + if (hasBuild(props)) { + const paths = yield* resolveBuildPaths(props.build); + yield* session.note(`Building Docker image: ${ref}`); + yield* docker.image.build({ + tag: ref, + context: paths.context, + file: paths.dockerfile, + platform: props.build.platform, + target: props.build.target, + "build-arg": props.build.args, + "cache-from": props.build.cacheFrom, + "cache-to": props.build.cacheTo, + args: props.build.options, + }); + nextContextHash = yield* contextHash(props); + } else { + const sourceRef = imageSourceRef(props.image); + if (!isLocalImageSource(props.image)) { + const source = yield* docker.image + .inspect(sourceRef) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + if (!source) { + yield* session.note(`Pulling Docker image: ${sourceRef}`); + yield* docker.image.pull(sourceRef); + } + } + yield* session.note(`Tagging Docker image: ${sourceRef} -> ${ref}`); + yield* docker.image.tag(sourceRef, ref); + } - if (props.registry && !props.skipPush) { - const pushed = yield* pushImageToRegistry( - ref, - props.registry satisfies RegistryPushCredentials, - ); - finalRef = pushed.imageRef; - repoDigest = pushed.repoDigest; - } + // Read the freshly built/tagged image's id and creation time straight + // from Docker rather than synthesizing a wall-clock timestamp. + const inspected = yield* docker.image.inspect(ref); + const currentImageId = inspected?.Id; + const builtAt = Date.parse(inspected?.Created ?? ""); - return { - kind: "Image" as const, - name: repositoryFromImageRef(finalRef), - imageRef: finalRef, - imageId: currentImageId, - repoDigest, - tag, - builtAt, - contextHash: nextContextHash, - }; - }), - delete: Effect.fn(function* () { - // Docker images are intentionally left in place. Tags and image ids are - // commonly shared by developer workflows outside Alchemy. + if (props.registry && !props.skipPush) { + repoDigest = yield* docker.image + .push(ref, props.registry) + .pipe( + Effect.map((result) => parseRepoDigest(ref, result.stdout)), + ); + } + + return { + kind: "Image" as const, + name: repositoryFromImageRef(finalRef), + imageRef: finalRef, + imageId: currentImageId, + repoDigest, + tag, + builtAt, + contextHash: nextContextHash, + }; + }), + delete: Effect.fn(function* () { + // Docker images are intentionally left in place. Tags and image ids are + // commonly shared by developer workflows outside Alchemy. + }), + }); }), - }); + ); const imageSourceRef = (source: ImageSource): string => typeof source === "string" ? source : source.imageRef; diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index f9811c9763..cf158a52d3 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -1,15 +1,12 @@ import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import { identity } from "effect/Function"; import { Unowned } from "../AdoptPolicy.ts"; -import { deepEqual, isResolved } from "../Diff.ts"; +import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { - createNetwork, - inspectNetworkInfo, - removeNetwork, - type NetworkInfo, -} from "./DockerApi.ts"; +import { Docker } from "./DockerClient.ts"; export interface NetworkProps { /** @@ -72,69 +69,78 @@ export interface Network extends Resource< export const Network = Resource("Docker.Network"); export const NetworkProvider = () => - Provider.succeed(Network, { - list: () => Effect.succeed([]), - read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const name = yield* networkName(id, olds ?? {}, instanceId); - const info = yield* inspectNetworkInfo(name); - if (!info) return undefined; - const attrs = toNetworkAttributes(info); - return output ? attrs : Unowned(attrs); - }), - diff: Effect.fn(function* ({ news, olds }) { - if (!isResolved(news)) return undefined; - const oldComparable = { - name: olds?.name, - driver: olds?.driver ?? "bridge", - enableIPv6: olds?.enableIPv6 ?? false, - labels: olds?.labels ?? {}, - }; - const newComparable = { - name: news?.name, - driver: news?.driver ?? "bridge", - enableIPv6: news?.enableIPv6 ?? false, - labels: news?.labels ?? {}, - }; - if (!deepEqual(oldComparable, newComparable)) { - return { action: "replace" as const, deleteFirst: true }; - } - }), - reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { - const name = - output?.name ?? (yield* networkName(id, news ?? {}, instanceId)); - const existing = yield* inspectNetworkInfo(name); - if (existing) { - return toNetworkAttributes(existing); - } - yield* session.note(`Creating Docker network: ${name}`); - const createdId = yield* createNetwork({ - name, - driver: news?.driver ?? "bridge", - enableIPv6: news?.enableIPv6, - labels: news?.labels, - }).pipe( - Effect.catchIf( - (error) => - error.message.includes(`network with name ${name} already exists`), - () => Effect.succeed(undefined), + Provider.effect( + Network, + Effect.gen(function* () { + const docker = yield* Docker; + + return Network.Provider.of({ + list: () => Effect.succeed([]), + read: Effect.fn(({ id, instanceId, olds, output }) => + networkName(id, olds ?? {}, instanceId).pipe( + Effect.flatMap(docker.network.inspect), + Effect.map(toNetworkAttributes), + Effect.map(output ? identity : Unowned), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ), ), - ); - const info = yield* inspectNetworkInfo(createdId ?? name); - if (!info) { - return yield* Effect.die( - `Docker network could not be inspected: ${name}`, - ); - } - return toNetworkAttributes(info); - }), - delete: Effect.fn(function* ({ output, session }) { - yield* session.note(`Removing Docker network: ${output.name}`); - yield* removeNetwork(output.id); + diff: Effect.fn(function* ({ id, output, instanceId, news }) { + if (!isResolved(news) || !output) return undefined; + const args = yield* makeNetworkArgs(id, news, instanceId); + if ( + output.name !== args.name || + output.driver !== args.driver || + output.enableIPv6 !== args.ipv6 || + !Equal.equals(output.labels, args.label) + ) { + return { action: "replace", deleteFirst: true }; + } + return { action: "noop" }; + }), + reconcile: Effect.fn(function* ({ output, id, instanceId, news }) { + if (output) { + const refreshed = yield* docker.network.inspect(output.id).pipe( + Effect.map(toNetworkAttributes), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + if (refreshed) return refreshed; + } + return yield* makeNetworkArgs(id, news, instanceId).pipe( + Effect.flatMap(docker.network.create), + Effect.map((result) => result.stdout), + Effect.flatMap((createdId) => docker.network.inspect(createdId)), + Effect.map(toNetworkAttributes), + ); + }), + delete: Effect.fn(({ output }) => + docker.network + .remove(output.id) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + ), + ), + ), + }); }), - }); + ); -const networkName = (id: string, props: NetworkProps, instanceId: string) => - props.name +const networkName = ( + id: string, + props: NetworkProps | undefined, + instanceId: string, +) => + props?.name ? Effect.succeed(props.name) : createPhysicalName({ id, @@ -143,8 +149,22 @@ const networkName = (id: string, props: NetworkProps, instanceId: string) => lowercase: true, }); +const makeNetworkArgs = ( + id: string, + props: NetworkProps | undefined, + instanceId: string, +) => + networkName(id, props, instanceId).pipe( + Effect.map((name) => ({ + name, + driver: props?.driver ?? "bridge", + ipv6: props?.enableIPv6 ?? false, + label: props?.labels ?? {}, + })), + ); + export const toNetworkAttributes = ( - info: NetworkInfo, + info: Docker.InspectedNetwork, ): Network["Attributes"] => ({ id: info.Id, name: info.Name, diff --git a/packages/alchemy/src/Docker/Providers.ts b/packages/alchemy/src/Docker/Providers.ts index 7cd4e63d47..955dd578ac 100644 --- a/packages/alchemy/src/Docker/Providers.ts +++ b/packages/alchemy/src/Docker/Providers.ts @@ -1,6 +1,7 @@ import * as Layer from "effect/Layer"; import * as Provider from "../Provider.ts"; import { Container, ContainerProvider } from "./Container.ts"; +import { DockerLive } from "./DockerClient.ts"; import { Image, ImageProvider } from "./Image.ts"; import { Network, NetworkProvider } from "./Network.ts"; import { RemoteImage, RemoteImageProvider } from "./RemoteImage.ts"; @@ -32,4 +33,5 @@ export const providers = () => VolumeProvider(), ), ), + Layer.provideMerge(DockerLive), ); diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index cea0d0dc70..78b1d1c328 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -1,8 +1,8 @@ import * as Effect from "effect/Effect"; -import { deepEqual, isResolved } from "../Diff.ts"; +import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { imageCreatedAt, inspectImageInfo, pullImage } from "./DockerApi.ts"; +import { Docker } from "./DockerClient.ts"; export interface RemoteImageProps { /** Docker image name, without tag. */ @@ -23,7 +23,6 @@ export interface RemoteImage extends Resource< "Docker.RemoteImage", RemoteImageProps, { - kind: "RemoteImage"; /** Full image reference. */ imageRef: string; /** Local image id after pull when available. */ @@ -67,46 +66,60 @@ export interface RemoteImage extends Resource< export const RemoteImage = Resource("Docker.RemoteImage"); export const RemoteImageProvider = () => - Provider.succeed(RemoteImage, { - list: () => Effect.succeed([]), - read: Effect.fn(function* ({ olds, output }) { - const ref = output?.imageRef ?? remoteImageRef(olds); - const image = yield* inspectImageInfo(ref); - if (!image) return undefined; - return { - kind: "RemoteImage" as const, - imageRef: ref, - imageId: image.Id, - createdAt: output?.createdAt ?? imageCreatedAt(image), - name: olds.name, - tag: olds.tag ?? "latest", - }; - }), - diff: Effect.fn(function* ({ news, olds }) { - if (!isResolved(news)) return undefined; - if (!deepEqual(olds, news) || news.alwaysPull !== false) { - return { action: "update" as const }; - } - }), - reconcile: Effect.fn(function* ({ news, session }) { - const ref = remoteImageRef(news); - yield* session.note(`Pulling Docker image: ${ref}`); - yield* pullImage(ref, { platform: news.platform }); - const inspected = yield* inspectImageInfo(ref); - return { - kind: "RemoteImage" as const, - imageRef: ref, - imageId: inspected?.Id, - createdAt: imageCreatedAt(inspected), - name: news.name, - tag: news.tag ?? "latest", - }; - }), - delete: Effect.fn(function* () { - // Remote images are not removed on destroy because tags may be shared by - // unrelated local stacks or developer workflows. + Provider.effect( + RemoteImage, + Effect.gen(function* () { + const docker = yield* Docker; + + return RemoteImage.Provider.of({ + list: () => Effect.succeed([]), + read: Effect.fn(function* ({ olds, output }) { + const ref = output?.imageRef ?? remoteImageRef(olds); + return yield* docker.image.inspect(ref).pipe( + Effect.map((image) => ({ + imageRef: ref, + imageId: image.Id, + createdAt: output?.createdAt ?? Date.parse(image.Created ?? ""), + name: olds.name, + tag: olds.tag ?? "latest", + })), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + }), + diff: Effect.fn(function* ({ output, news }) { + if (!isResolved(news)) return undefined; + if ( + !output || + news.alwaysPull !== false || + output.imageRef !== remoteImageRef(news) + ) { + return { action: "update" }; + } + }), + reconcile: Effect.fn(function* ({ news, session }) { + const ref = remoteImageRef(news); + yield* session.note(`Pulling Docker image: ${ref}`); + yield* docker.image.pull(ref, news.platform); + const inspected = yield* docker.image.inspect(ref); + return { + imageRef: ref, + imageId: inspected.Id, + createdAt: Date.parse(inspected.Created ?? ""), + name: news.name, + tag: news.tag ?? "latest", + }; + }), + delete: Effect.fn(function* () { + // Remote images are not removed on destroy because tags may be shared by + // unrelated local stacks or developer workflows. + }), + }); }), - }); + ); export const remoteImageRef = (props: RemoteImageProps): string => `${props.name}:${props.tag ?? "latest"}`; diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index d202026a29..dca0e75690 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -1,16 +1,12 @@ import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import { identity } from "effect/Function"; import { Unowned } from "../AdoptPolicy.ts"; -import { deepEqual, isResolved } from "../Diff.ts"; +import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { - createVolume, - inspectVolumeInfo, - normalizeLabels, - removeVolume, - type VolumeInfo, -} from "./DockerApi.ts"; +import { Docker } from "./DockerClient.ts"; export interface VolumeLabel { /** Label name. */ @@ -31,7 +27,7 @@ export interface VolumeProps { /** Driver-specific options. */ driverOpts?: Record; /** Custom metadata labels. */ - labels?: VolumeLabel[] | Record; + labels?: Record; } export interface Volume extends Resource< @@ -94,60 +90,58 @@ export interface Volume extends Resource< export const Volume = Resource("Docker.Volume"); export const VolumeProvider = () => - Provider.succeed(Volume, { - list: () => Effect.succeed([]), - read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const name = yield* volumeName(id, olds ?? {}, instanceId); - const info = yield* inspectVolumeInfo(name); - if (!info) return undefined; - const attrs = toVolumeAttributes(info); - return output ? attrs : Unowned(attrs); - }), - diff: Effect.fn(function* ({ news, olds }) { - if (!isResolved(news)) return undefined; - const oldComparable = { - name: olds?.name, - driver: olds?.driver ?? "local", - driverOpts: olds?.driverOpts ?? {}, - labels: normalizeLabels(olds?.labels), - }; - const newComparable = { - name: news?.name, - driver: news?.driver ?? "local", - driverOpts: news?.driverOpts ?? {}, - labels: normalizeLabels(news?.labels), - }; - if (!deepEqual(oldComparable, newComparable)) { - return { action: "replace" as const, deleteFirst: true }; - } - }), - reconcile: Effect.fn(function* ({ id, instanceId, news, output, session }) { - const name = - output?.name ?? (yield* volumeName(id, news ?? {}, instanceId)); - const existing = yield* inspectVolumeInfo(name); - if (existing) { - return toVolumeAttributes(existing); - } - yield* session.note(`Creating Docker volume: ${name}`); - const createdName = yield* createVolume({ - name, - driver: news?.driver ?? "local", - driverOpts: news?.driverOpts, - labels: normalizeLabels(news?.labels), + Provider.effect( + Volume, + Effect.gen(function* () { + const docker = yield* Docker; + + return Volume.Provider.of({ + list: () => Effect.succeed([]), + read: Effect.fn(({ id, instanceId, olds, output }) => + volumeName(id, olds ?? {}, instanceId).pipe( + Effect.flatMap(docker.volume.inspect), + Effect.map(toVolumeAttributes), + Effect.map(output ? identity : Unowned), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ), + ), + diff: Effect.fn(function* ({ id, instanceId, output, news }) { + if (!isResolved(news)) return undefined; + const args = yield* makeVolumeArgs(id, news, instanceId); + if ( + output?.name !== args.name || + output?.driver !== args.driver || + !Equal.equals(output?.driverOpts, args.driverOpts) || + !Equal.equals(output?.labels, args.labels) + ) { + return { action: "replace" as const, deleteFirst: true }; + } + }), + reconcile: Effect.fn(({ id, instanceId, news }) => + makeVolumeArgs(id, news, instanceId).pipe( + Effect.flatMap(docker.volume.create), + Effect.flatMap((result) => docker.volume.inspect(result.stdout)), + Effect.map(toVolumeAttributes), + ), + ), + delete: Effect.fn(({ output }) => + docker.volume + .remove(output.name) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + ), + ), + ), }); - const info = yield* inspectVolumeInfo(createdName); - if (!info) { - return yield* Effect.die( - `Docker volume was created but could not be inspected: ${createdName}`, - ); - } - return toVolumeAttributes(info); }), - delete: Effect.fn(function* ({ output, session }) { - yield* session.note(`Removing Docker volume: ${output.name}`); - yield* removeVolume(output.name); - }), - }); + ); const volumeName = (id: string, props: VolumeProps, instanceId: string) => props.name @@ -159,7 +153,19 @@ const volumeName = (id: string, props: VolumeProps, instanceId: string) => lowercase: true, }); -export const toVolumeAttributes = (info: VolumeInfo): Volume["Attributes"] => ({ +const makeVolumeArgs = (id: string, props: VolumeProps, instanceId: string) => + volumeName(id, props, instanceId).pipe( + Effect.map((name) => ({ + name, + driver: props.driver ?? "local", + driverOpts: props.driverOpts, + labels: props.labels, + })), + ); + +export const toVolumeAttributes = ( + info: Docker.InspectedVolume, +): Volume["Attributes"] => ({ id: info.Name, name: info.Name, driver: info.Driver, diff --git a/packages/alchemy/src/Docker/index.ts b/packages/alchemy/src/Docker/index.ts index b880b0a03a..11f27256f4 100644 --- a/packages/alchemy/src/Docker/index.ts +++ b/packages/alchemy/src/Docker/index.ts @@ -1,4 +1,5 @@ export * from "./Container.ts"; +export * from "./DockerClient.ts"; export * from "./Image.ts"; export * from "./Network.ts"; export * from "./Providers.ts"; diff --git a/packages/alchemy/test/Docker/Docker.integration.test.ts b/packages/alchemy/test/Docker/Docker.integration.test.ts index b662506eb2..292020fae6 100644 --- a/packages/alchemy/test/Docker/Docker.integration.test.ts +++ b/packages/alchemy/test/Docker/Docker.integration.test.ts @@ -1,6 +1,5 @@ import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; import * as Docker from "@/Docker"; -import { inspectContainerInfo, runDockerCommand } from "@/Docker/DockerApi"; import * as Provider from "@/Provider"; import { inMemoryState } from "@/State"; import * as Test from "@/Test/Vitest"; @@ -80,7 +79,15 @@ test.provider("provider diff canaries for replacements and registry refs", () => news: { name: "data", labels: { usage: "new" } }, oldBindings: [], newBindings: [], - output: undefined, + output: { + id: "data", + name: "data", + driver: "local", + driverOpts: {}, + labels: { usage: "old" }, + mountpoint: undefined, + createdAt: 0, + }, }); expect(volumeDiff).toEqual({ action: "replace", deleteFirst: true }); @@ -91,7 +98,14 @@ test.provider("provider diff canaries for replacements and registry refs", () => news: { name: "app", labels: { usage: "new" } }, oldBindings: [], newBindings: [], - output: undefined, + output: { + id: "app", + name: "app", + driver: "bridge", + enableIPv6: false, + labels: { usage: "old" }, + createdAt: 0, + }, }); expect(networkDiff).toEqual({ action: "replace", deleteFirst: true }); @@ -135,11 +149,14 @@ describe.sequential("Docker resources", () => { "network refuses pre-existing Docker network unless explicitly adopted", (stack) => Effect.gen(function* () { + const docker = yield* Docker.Docker; const networkName = "alchemy-test-network-adoption"; - yield* runDockerCommand(["network", "rm", networkName]).pipe( - Effect.ignore, - ); - yield* runDockerCommand(["network", "create", networkName]); + yield* docker.network + .remove(networkName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); + yield* docker.network.create({ name: networkName, driver: "bridge" }); try { const error = yield* stack .deploy( @@ -168,9 +185,7 @@ describe.sequential("Docker resources", () => { expect(network.id.length).toBeGreaterThan(0); } finally { yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["network", "rm", networkName]).pipe( - Effect.ignore, - ); + yield* docker.network.remove(networkName).pipe(Effect.ignore); } }), { timeout: 120000 }, @@ -180,27 +195,27 @@ describe.sequential("Docker resources", () => { "network adopts an existing same-name Docker network with stack adoption", (stack) => Effect.gen(function* () { + const docker = yield* Docker.Docker; const networkName = "alchemy-test-network-adopt-existing"; - yield* runDockerCommand(["network", "rm", networkName]).pipe( - Effect.ignore, - ); - yield* runDockerCommand(["network", "create", networkName]); - try { - const network = yield* stack.deploy( - Effect.gen(function* () { - return yield* Docker.Network("existing-network", { - name: networkName, - }); - }), + yield* docker.network + .remove(networkName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), ); - expect(network.name).toBe(networkName); - expect(network.id.length).toBeGreaterThan(0); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["network", "rm", networkName]).pipe( - Effect.ignore, - ); - } + yield* docker.network.create({ name: networkName, driver: "bridge" }); + const networkInfo = yield* docker.network.inspect(networkName); + console.log(networkInfo); + const network = yield* stack.deploy( + Effect.gen(function* () { + return yield* Docker.Network("existing-network", { + name: networkName, + driver: "bridge", + }); + }), + ); + expect(network.name).toBe(networkName); + expect(network.id.length).toBeGreaterThan(0); + yield* stack.destroy(); }), { timeout: 120000 }, ); @@ -209,28 +224,23 @@ describe.sequential("Docker resources", () => { "volume adopts an existing Docker volume", (stack) => Effect.gen(function* () { + const docker = yield* Docker.Docker; const volumeName = "alchemy-test-volume-adopt-existing"; - yield* runDockerCommand(["volume", "rm", volumeName]).pipe( - Effect.ignore, - ); - yield* runDockerCommand(["volume", "create", volumeName]); - try { - const volume = yield* stack.deploy( - Effect.gen(function* () { - return yield* Docker.Volume("existing-volume", { - name: volumeName, - }); - }), - ); - expect(volume.name).toBe(volumeName); - expect(volume.id).toBe(volumeName); - expect(volume.driver).toBe("local"); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["volume", "rm", volumeName]).pipe( - Effect.ignore, + yield* docker.volume + .remove(volumeName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), ); - } + yield* docker.volume.create({ name: volumeName }); + const volume = yield* stack.deploy( + Docker.Volume("existing-volume", { + name: volumeName, + }), + ); + expect(volume.name).toBe(volumeName); + expect(volume.id).toBe(volumeName); + expect(volume.driver).toBe("local"); + yield* stack.destroy(); }), { timeout: 120000 }, ); @@ -239,12 +249,15 @@ describe.sequential("Docker resources", () => { "image string source pulls before tagging when the source tag is absent", (stack) => Effect.gen(function* () { + const docker = yield* Docker.Docker; const sourceRef = "hello-world:latest"; const targetTag = "alchemy-test-remote-source"; const targetRef = `hello-world:${targetTag}`; - yield* runDockerCommand(["rmi", "-f", targetRef, sourceRef]).pipe( - Effect.ignore, - ); + yield* docker.image + .remove([targetRef, sourceRef], true) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); try { const image = yield* stack.deploy( Effect.gen(function* () { @@ -258,9 +271,9 @@ describe.sequential("Docker resources", () => { expect(image.imageId?.length).toBeGreaterThan(0); } finally { yield* stack.destroy().pipe(Effect.ignore); - yield* runDockerCommand(["rmi", "-f", targetRef, sourceRef]).pipe( - Effect.ignore, - ); + yield* docker.image + .remove([targetRef, sourceRef], true) + .pipe(Effect.ignore); } }), { timeout: 120000 }, @@ -272,6 +285,7 @@ describe.sequential("Docker resources", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const docker = yield* Docker.Docker; const root = yield* fs.makeTempDirectory({ prefix: "alchemy-docker-image-", }); @@ -297,9 +311,7 @@ describe.sequential("Docker resources", () => { } finally { yield* stack.destroy().pipe(Effect.ignore); if (imageRef) { - yield* runDockerCommand(["rmi", "-f", imageRef]).pipe( - Effect.ignore, - ); + yield* docker.image.remove(imageRef, true).pipe(Effect.ignore); } yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore); } @@ -330,6 +342,7 @@ describe.sequential("Docker resources", () => { "container inspect returns bound host ports", (stack) => Effect.gen(function* () { + const docker = yield* Docker.Docker; const hostPort = yield* freeHostPort; let containerName: string | undefined; try { @@ -346,14 +359,19 @@ describe.sequential("Docker resources", () => { containerName = container.name; expect(container.name.length).toBeGreaterThan(0); expect(container.state).toBe("running"); - const runtime = yield* Docker.inspectContainer(container.name); - expect(runtime.ports["80/tcp"]).toBe(hostPort); + const runtime = yield* docker.container.inspect(container.name); + expect(runtime.NetworkSettings.Ports).toMatchObject({ + "80/tcp": [ + { HostIp: "0.0.0.0", HostPort: `${hostPort}` }, + { HostIp: "::", HostPort: `${hostPort}` }, + ], + }); } finally { yield* stack.destroy().pipe(Effect.ignore); if (containerName) { - yield* runDockerCommand(["rm", "-f", containerName]).pipe( - Effect.ignore, - ); + yield* docker.container + .remove(containerName, true) + .pipe(Effect.ignore); } } }), @@ -364,6 +382,7 @@ describe.sequential("Docker resources", () => { "container network aliases update without replacing the container", (stack) => Effect.gen(function* () { + const docker = yield* Docker.Docker; let containerName: string | undefined; let networkName: string | undefined; try { @@ -387,7 +406,7 @@ describe.sequential("Docker resources", () => { const second = yield* deployWithAlias("new-alias"); expect(second.container.id).toBe(first.container.id); - const info = yield* inspectContainerInfo(second.container.name); + const info = yield* docker.container.inspect(second.container.name); const aliases = info?.NetworkSettings.Networks?.[second.network.name]?.Aliases ?? []; @@ -396,14 +415,12 @@ describe.sequential("Docker resources", () => { } finally { yield* stack.destroy().pipe(Effect.ignore); if (containerName) { - yield* runDockerCommand(["rm", "-f", containerName]).pipe( - Effect.ignore, - ); + yield* docker.container + .remove(containerName, true) + .pipe(Effect.ignore); } if (networkName) { - yield* runDockerCommand(["network", "rm", networkName]).pipe( - Effect.ignore, - ); + yield* docker.network.remove(networkName).pipe(Effect.ignore); } } }), diff --git a/packages/alchemy/test/Docker/DockerApi.test.ts b/packages/alchemy/test/Docker/DockerApi.test.ts index 44472604be..9f5f6a3d38 100644 --- a/packages/alchemy/test/Docker/DockerApi.test.ts +++ b/packages/alchemy/test/Docker/DockerApi.test.ts @@ -3,32 +3,16 @@ import { buildImageBuildArgs, buildNetworkCreateArgs, buildVolumeCreateArgs, - durationToNanoseconds, - normalizeDuration, parseRepoDigest, repositoryFromImageRef, toRuntimeInfo, withRegistryHost, } from "@/Docker/DockerApi"; -import { compareEnv, compareHealthcheck } from "@/Docker/Container"; import { desiredImageRef, localImageRef } from "@/Docker/Image"; import { describe, expect, it } from "@effect/vitest"; import * as Redacted from "effect/Redacted"; describe("Docker CLI helpers", () => { - it("normalizes Docker duration values", () => { - expect(normalizeDuration(30)).toBe("30s"); - expect(normalizeDuration("500ms")).toBe("500ms"); - expect(durationToNanoseconds("1.5s")).toBe(1_500_000_000); - expect(durationToNanoseconds("2m")).toBe(120_000_000_000); - const invalidDuration = "30" as unknown as Parameters< - typeof normalizeDuration - >[0]; - expect(() => normalizeDuration(invalidDuration)).toThrow( - /Invalid duration format/, - ); - }); - it("builds volume create argv", () => { expect( buildVolumeCreateArgs({ @@ -154,31 +138,6 @@ describe("Docker CLI helpers", () => { ).toEqual({ ports: { "80/tcp": 8080, "81/tcp": 8081 } }); }); - it("compares Redacted env and healthcheck values", () => { - expect( - compareEnv({ TOKEN: Redacted.value(Redacted.make("abc")) }, [ - "PATH=/usr/bin", - "TOKEN=abc", - ]), - ).toBe(true); - expect( - compareHealthcheck( - { cmd: "curl -f http://localhost/", interval: "5s" }, - { - Test: ["CMD-SHELL", "curl -f http://localhost/"], - Interval: 5_000_000_000, - }, - ), - ).toBe(true); - expect( - compareHealthcheck(undefined, { - Test: ["CMD-SHELL", "curl -f http://localhost/"], - Interval: 5_000_000_000, - }), - ).toBe(true); - expect(compareHealthcheck({ cmd: "true" }, undefined)).toBe(false); - }); - it("normalizes registry push refs and parses repo digest", () => { expect(withRegistryHost("app:latest", { server: "ghcr.io" })).toBe( "ghcr.io/app:latest", From 4cee735c312fb73234717bda4e714411134bba46 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 17:40:23 -0400 Subject: [PATCH 07/25] update aws and cloudflare to use new docker api --- packages/alchemy/src/AWS/ECS/Task.ts | 8 +- packages/alchemy/src/AWS/Providers.ts | 2 + packages/alchemy/src/Bundle/Docker.ts | 194 --------------- .../Cloudflare/Container/ContainerProvider.ts | 25 +- packages/alchemy/src/Cloudflare/Providers.ts | 2 + packages/alchemy/test/Bundle/Docker.test.ts | 229 ++++++++---------- 6 files changed, 114 insertions(+), 346 deletions(-) diff --git a/packages/alchemy/src/AWS/ECS/Task.ts b/packages/alchemy/src/AWS/ECS/Task.ts index 1457d35db8..25114bfd3c 100644 --- a/packages/alchemy/src/AWS/ECS/Task.ts +++ b/packages/alchemy/src/AWS/ECS/Task.ts @@ -12,9 +12,7 @@ import type * as rolldown from "rolldown"; import { AlchemyContext } from "../../AlchemyContext.ts"; import * as Bundle from "../../Bundle/Bundle.ts"; import { - dockerBuild, materializeDockerfile, - pushImage, writeContextFiles, } from "../../Bundle/Docker.ts"; import { @@ -22,6 +20,7 @@ import { getStableContextDir, } from "../../Bundle/TempRoot.ts"; import { isResolved } from "../../Diff.ts"; +import { Docker } from "../../Docker/DockerClient.ts"; import * as Output from "../../Output.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; @@ -345,6 +344,7 @@ export const TaskProvider = () => Task, Effect.gen(function* () { const stack = yield* Stack; + const docker = yield* Docker; const { dotAlchemy } = yield* AlchemyContext; const fs = yield* FileSystem.FileSystem; @@ -728,11 +728,11 @@ await Effect.runPromise(program); yield* writeContextFiles(contextDir, [ { path: "index.mjs", content: code }, ]); - yield* dockerBuild({ + yield* docker.image.build({ tag: imageUri, context: contextDir, }); - yield* pushImage(imageUri, { + yield* docker.image.push(imageUri, { username: "AWS", password, server: registry, diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index 8c9ea71106..fb4bb15b89 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -18,6 +18,7 @@ import * as Schedule from "effect/Schedule"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { CredentialsStoreLive } from "../Auth/Credentials.ts"; import * as Command from "../Command/index.ts"; +import { DockerLive } from "../Docker/DockerClient.ts"; import { KeyPair, KeyPairProvider } from "../KeyPair.ts"; import * as Provider from "../Provider.ts"; import { Random, RandomProvider } from "../Random.ts"; @@ -592,6 +593,7 @@ export const providers = () => KeyPairProvider(), RandomProvider(), Assets.AssetsLive, + DockerLive, ), ), Layer.provideMerge(Region.fromEnvironment), diff --git a/packages/alchemy/src/Bundle/Docker.ts b/packages/alchemy/src/Bundle/Docker.ts index 6296aee6dd..2ac4966178 100644 --- a/packages/alchemy/src/Bundle/Docker.ts +++ b/packages/alchemy/src/Bundle/Docker.ts @@ -1,39 +1,6 @@ -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Stream from "effect/Stream"; -import { ChildProcess } from "effect/unstable/process"; -import { exec } from "../Util/exec.ts"; - -export class DockerCommandError extends Data.TaggedError("DockerCommandError")<{ - readonly command: string; - readonly stderr: string; - readonly exitCode: number; - readonly message: string; -}> {} - -export interface RegistryAuth { - readonly username: string; - readonly password: string; - /** - * Registry host only (no `https://`, no path), e.g. `123456789.dkr.ecr.us-east-1.amazonaws.com`. - */ - readonly server: string; -} - -export interface DockerBuildOptions { - /** Image reference passed to `docker build -t`. */ - readonly tag: string; - /** Build context directory (`.` argument to `docker build`). */ - readonly context: string; - readonly platform?: string; - readonly target?: string; - readonly buildArgs?: Record; - /** Appended to `docker build` before the final context path. */ - readonly extraArgs?: ReadonlyArray; - readonly env?: Record; -} export const materializeDockerfile = Effect.fn(function* ( dockerfile: string, @@ -66,164 +33,3 @@ export const writeContextFiles = Effect.fn(function* ( } } }); - -export const runDockerCommand = Effect.fn(function* ( - args: ReadonlyArray, - options?: { - cwd?: string; - env?: Record; - /** Passed to the process stdin (e.g. for `docker login --password-stdin`). */ - stdin?: string; - }, -) { - const command = `docker ${args.join(" ")}`; - const env = { ...process.env, ...options?.env }; - const commandOptions: ChildProcess.CommandOptions = { - env, - ...(options?.stdin !== undefined - ? { - stdin: Stream.succeed(new TextEncoder().encode(options.stdin)), - } - : {}), - }; - const child = options?.cwd - ? ChildProcess.setCwd( - ChildProcess.make("docker", args, commandOptions), - options.cwd, - ) - : ChildProcess.make("docker", args, commandOptions); - - const { stdout, stderr, exitCode } = yield* exec(child).pipe( - Effect.catch((e) => - Effect.fail( - new DockerCommandError({ - command, - stderr: e instanceof Error ? e.message : String(e), - exitCode: 1, - message: e instanceof Error ? e.message : String(e), - }), - ), - ), - ); - - if (exitCode !== 0) { - return yield* Effect.fail( - new DockerCommandError({ - command, - stderr, - exitCode, - message: - `Docker command failed (${exitCode}): ${command}\n${stderr}`.trim(), - }), - ); - } - - return { stdout, stderr }; -}); - -/** - * Run `docker build` with standard flags from {@link DockerBuildOptions}. - */ -export const dockerBuild = Effect.fn(function* (options: DockerBuildOptions) { - const args: string[] = ["build", "-t", options.tag]; - if (options.platform) { - args.push("--platform", options.platform); - } - if (options.target) { - args.push("--target", options.target); - } - if (options.buildArgs) { - for (const [k, v] of Object.entries(options.buildArgs)) { - args.push("--build-arg", `${k}=${v}`); - } - } - if (options.extraArgs?.length) { - args.push(...options.extraArgs); - } - args.push(options.context); - - yield* runDockerCommand(args, { env: options.env }); -}); - -/** - * Get the image ID (content-addressable digest) of a locally-built image. - */ -export const getDockerImageId = Effect.fn(function* (tag: string) { - const { stdout } = yield* runDockerCommand([ - "inspect", - "--format", - "{{.Id}}", - tag, - ]); - return stdout.trim(); -}); - -/** - * Tag a local image with a new reference. - */ -export const dockerTag = Effect.fn(function* (source: string, target: string) { - yield* runDockerCommand(["tag", source, target]); -}); - -/** - * Log in to a registry using `docker login --password-stdin` (no password on argv). - */ -export const dockerLogin = Effect.fn(function* ( - auth: RegistryAuth, - options?: { env?: Record }, -) { - yield* runDockerCommand( - ["login", "-u", auth.username, "--password-stdin", auth.server], - { - env: options?.env, - stdin: auth.password, - }, - ); -}); - -/** - * Push an image ref. When `auth` is set, uses an isolated `DOCKER_CONFIG` - * directory so concurrent deploys do not race on global docker credentials. - */ -export const pushImage = Effect.fn(function* ( - imageRef: string, - auth?: RegistryAuth, -) { - if (auth) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const configDir = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-", - }); - // Write the registry credentials directly into an isolated docker config - // as a plaintext `auths` entry and skip `docker login` entirely. - // - // `docker login` is the wrong tool here: on macOS Docker Desktop it routes - // through the shared `osxkeychain`/`desktop` credential helper *regardless* - // of an isolated DOCKER_CONFIG, so concurrent deploys either race the system - // keychain (`The specified item already exists in the keychain (-25299)`) or - // land the credential in the helper — leaving this isolated config without - // an `auths` entry, so the subsequent `docker push` fails with "no basic - // auth credentials". Embedding the base64 `auth` inline (the same thing - // `docker login` would write when no credsStore is configured) makes each - // deploy fully self-contained: no credential helper, no keychain, no login - // race. Only `push` reads this config; `build`/`pull`/`tag` keep using the - // global docker config (buildx builders, `docker context`, etc. intact). - const token = yield* Effect.sync(() => - Buffer.from(`${auth.username}:${auth.password}`).toString("base64"), - ); - const config = JSON.stringify({ - auths: { [auth.server]: { auth: token } }, - }); - yield* fs.writeFileString(path.join(configDir, "config.json"), config); - const env = { ...process.env, DOCKER_CONFIG: configDir }; - return yield* runDockerCommand(["push", imageRef], { env }).pipe( - Effect.ensuring( - fs - .remove(configDir, { recursive: true }) - .pipe(Effect.catch(() => Effect.void)), - ), - ); - } - yield* runDockerCommand(["push", imageRef]); -}); diff --git a/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts b/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts index 776381f657..9347c8fe6f 100644 --- a/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts @@ -5,17 +5,14 @@ import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import { Unowned } from "../../AdoptPolicy.ts"; import { AlchemyContext } from "../../AlchemyContext.ts"; -import { hashDirectory } from "../../Command/Memo.ts"; import { - dockerBuild, - dockerTag, materializeDockerfile, - pushImage, - runDockerCommand, writeContextFiles, } from "../../Bundle/Docker.ts"; import { getStableContextDir } from "../../Bundle/TempRoot.ts"; +import { hashDirectory } from "../../Command/Memo.ts"; import { deepEqual, isResolved } from "../../Diff.ts"; +import { Docker } from "../../Docker/DockerClient.ts"; import * as Provider from "../../Provider.ts"; import { type ResourceBinding } from "../../Resource.ts"; import { sha256Object } from "../../Util/sha256.ts"; @@ -65,6 +62,7 @@ export const LiveContainerProvider = () => const { dotAlchemy } = yield* AlchemyContext; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const docker = yield* Docker; const telemetry = yield* CloudflareLogs; @@ -226,13 +224,8 @@ export const LiveContainerProvider = () => if (session) { yield* session.note(`Pulling container image ${build.image}...`); } - yield* runDockerCommand([ - "pull", - "--platform", - platform, - build.image, - ]); - yield* dockerTag(build.image, imageRef); + yield* docker.image.pull(build.image, platform); + yield* docker.image.tag(build.image, imageRef); } else if (build.kind === "external") { // Build the user's Dockerfile directly against their context dir so // relative `COPY`/`ADD` paths resolve as the author intended. @@ -242,11 +235,11 @@ export const LiveContainerProvider = () => if (session) { yield* session.note(`Building container image ${imageRef}...`); } - yield* dockerBuild({ + yield* docker.image.build({ tag: imageRef, context: build.context, platform, - extraArgs: ["-f", build.dockerfile], + file: build.dockerfile, }); } else { // Effect-native program: materialize the generated Dockerfile and @@ -281,7 +274,7 @@ export const LiveContainerProvider = () => content: f.content, })), ); - yield* dockerBuild({ + yield* docker.image.build({ tag: imageRef, context: contextDir, platform, @@ -312,7 +305,7 @@ export const LiveContainerProvider = () => ); } - yield* pushImage(imageRef, { + yield* docker.image.push(imageRef, { username, password: credentials.password, server: registryId, diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index 3e5d95105c..faa97ada6b 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -8,6 +8,7 @@ import * as Schedule from "effect/Schedule"; import { CredentialsStoreLive } from "../Auth/Credentials.ts"; import { ProfileLive } from "../Auth/Profile.ts"; import * as Command from "../Command/index.ts"; +import { DockerLive } from "../Docker/DockerClient.ts"; import { KeyPair, KeyPairProvider } from "../KeyPair.ts"; import * as Provider from "../Provider.ts"; import { Random, RandomProvider } from "../Random.ts"; @@ -764,6 +765,7 @@ export const providers = () => RandomProvider(), ), ), + Layer.provide(DockerLive), Layer.provideMerge(localRuntimeServices()), Layer.provideMerge(Credentials.fromAuthProvider()), Layer.provideMerge(CloudflareEnvironment.fromProfile()), diff --git a/packages/alchemy/test/Bundle/Docker.test.ts b/packages/alchemy/test/Bundle/Docker.test.ts index 876a79c986..7cd19c456c 100644 --- a/packages/alchemy/test/Bundle/Docker.test.ts +++ b/packages/alchemy/test/Bundle/Docker.test.ts @@ -1,95 +1,62 @@ -import { - DockerCommandError, - dockerBuild, - materializeDockerfile, - runDockerCommand, - writeContextFiles, -} from "@/Bundle/Docker"; +import { materializeDockerfile, writeContextFiles } from "@/Bundle/Docker"; +import { Docker, DockerLive } from "@/Docker"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; +import { expect, layer } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Result from "effect/Result"; import { spawnSync } from "node:child_process"; const dockerDaemonOk = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; -describe("docker context helpers", () => { +layer(NodeServices.layer)("docker context helpers", (it) => { it.effect("materializes a Dockerfile in the target directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ + const root = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-docker-ctx-", }); - try { - const ctx = path.join(root, "ctx"); - const dockerfile = yield* materializeDockerfile("FROM scratch\n", ctx); - expect(dockerfile).toBe(path.join(ctx, "Dockerfile")); - expect(yield* fs.exists(dockerfile)).toBe(true); - expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n"); - } finally { - yield* fs - .remove(root, { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); - } - }).pipe(Effect.provide(NodeServices.layer)), + const ctx = path.join(root, "ctx"); + const dockerfile = yield* materializeDockerfile("FROM scratch\n", ctx); + expect(dockerfile).toBe(path.join(ctx, "Dockerfile")); + expect(yield* fs.exists(dockerfile)).toBe(true); + expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n"); + }), ); it.effect("writes nested context files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ + const root = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-docker-path-", }); - try { - const ctx = path.join(root, "ctx"); - yield* writeContextFiles(ctx, [ - { path: "nested/hello.txt", content: "hi" }, - ]); - expect( - yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")), - ).toBe("hi"); - } finally { - yield* fs - .remove(root, { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); - } - }).pipe(Effect.provide(NodeServices.layer)), + const ctx = path.join(root, "ctx"); + yield* writeContextFiles(ctx, [ + { path: "nested/hello.txt", content: "hi" }, + ]); + expect( + yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")), + ).toBe("hi"); + }), ); }); -describe("runDockerCommand", () => { - it.effect("fails with DockerCommandError for invalid docker invocation", () => - Effect.gen(function* () { - const result = yield* Effect.result( - runDockerCommand([ - "inspect", - "--type=image", - "this-image-should-not-exist-alchemy-test:xyz", - ]), - ); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(DockerCommandError); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); - -describe("dockerBuild", () => { - if (dockerDaemonOk) { - it.effect("builds a minimal image with content Dockerfile", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-build-", - }); - try { +layer(Layer.provideMerge(DockerLive, NodeServices.layer))( + "dockerBuild", + (it) => { + if (dockerDaemonOk) { + it.effect("builds a minimal image with content Dockerfile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-build-", + }); const ctx = path.join(root, "ctx"); const tag = "alchemy-docker-test:minimal"; yield* materializeDockerfile( @@ -101,37 +68,32 @@ describe("dockerBuild", () => { ].join("\n"), ctx, ); - yield* dockerBuild({ + yield* docker.image.build({ tag, context: ctx, }); - const inspect = yield* runDockerCommand([ - "image", - "inspect", - tag, - "--format", - "{{.Id}}", - ]); - expect(inspect.stdout.trim().length).toBeGreaterThan(0); - yield* runDockerCommand(["rmi", "-f", tag]).pipe( - Effect.catch(() => Effect.void), - ); - } finally { - yield* fs - .remove(root, { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); + const inspect = yield* docker.image.inspect(tag); + expect(inspect.Id.length).toBeGreaterThan(0); + yield* docker.image + .remove(tag) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + ), + ); + }), + ); - it.effect("passes --platform and --build-arg", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-build-", - }); - try { + it.effect("passes --platform and --build-arg", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-build-", + }); const ctx = path.join(root, "ctx"); const tag = "alchemy-docker-test:args"; yield* materializeDockerfile( @@ -143,13 +105,13 @@ describe("dockerBuild", () => { ].join("\n"), ctx, ); - yield* dockerBuild({ + yield* docker.image.build({ tag, context: ctx, platform: "linux/amd64", - buildArgs: { FOO: "from-arg" }, + "build-arg": { FOO: "from-arg" }, }); - const out = yield* runDockerCommand([ + const out = yield* docker.run([ "run", "--rm", tag, @@ -157,25 +119,26 @@ describe("dockerBuild", () => { "/out.txt", ]); expect(out.stdout.trim()).toBe("from-arg"); - yield* runDockerCommand(["rmi", "-f", tag]).pipe( - Effect.catch(() => Effect.void), - ); - } finally { - yield* fs - .remove(root, { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); + yield* docker.image + .remove(tag) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + ), + ); + }), + ); - it.effect("respects multi-stage --target", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-build-", - }); - try { + it.effect("respects multi-stage --target", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-build-", + }); const ctx = path.join(root, "ctx"); const tag = "alchemy-docker-test:target"; yield* materializeDockerfile( @@ -189,12 +152,12 @@ describe("dockerBuild", () => { ].join("\n"), ctx, ); - yield* dockerBuild({ + yield* docker.image.build({ tag, context: ctx, target: "secondary", }); - const out = yield* runDockerCommand([ + const out = yield* docker.run([ "run", "--rm", tag, @@ -202,19 +165,21 @@ describe("dockerBuild", () => { "/stage.txt", ]); expect(out.stdout.trim()).toBe("secondary"); - yield* runDockerCommand(["rmi", "-f", tag]).pipe( - Effect.catch(() => Effect.void), - ); - } finally { - yield* fs - .remove(root, { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); - } else { - it.skip("builds a minimal image with content Dockerfile", () => {}); - it.skip("passes --platform and --build-arg", () => {}); - it.skip("respects multi-stage --target", () => {}); - } -}); + yield* docker.image + .remove(tag) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + ), + ); + }), + ); + } else { + it.skip("builds a minimal image with content Dockerfile", () => {}); + it.skip("passes --platform and --build-arg", () => {}); + it.skip("respects multi-stage --target", () => {}); + } + }, +); From 45b7a4bd75e0616daa44ffb7d421af6e9d342bd4 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 17:54:41 -0400 Subject: [PATCH 08/25] replace old docker utils with Docker.materialize --- packages/alchemy/src/AWS/ECS/Task.ts | 13 +-- packages/alchemy/src/Bundle/Docker.ts | 35 ------ packages/alchemy/src/Bundle/index.ts | 1 - .../Cloudflare/Container/ContainerProvider.ts | 18 +-- .../Container/LocalContainerProvider.ts | 15 ++- packages/alchemy/src/Cloudflare/Local.ts | 2 + packages/alchemy/src/Docker/DockerClient.ts | 29 +++++ packages/alchemy/test/Bundle/Docker.test.ts | 103 ++++++++++-------- 8 files changed, 110 insertions(+), 106 deletions(-) delete mode 100644 packages/alchemy/src/Bundle/Docker.ts diff --git a/packages/alchemy/src/AWS/ECS/Task.ts b/packages/alchemy/src/AWS/ECS/Task.ts index 25114bfd3c..f259f84d40 100644 --- a/packages/alchemy/src/AWS/ECS/Task.ts +++ b/packages/alchemy/src/AWS/ECS/Task.ts @@ -11,10 +11,6 @@ import * as Stream from "effect/Stream"; import type * as rolldown from "rolldown"; import { AlchemyContext } from "../../AlchemyContext.ts"; import * as Bundle from "../../Bundle/Bundle.ts"; -import { - materializeDockerfile, - writeContextFiles, -} from "../../Bundle/Docker.ts"; import { findCwdForBundle, getStableContextDir, @@ -724,10 +720,11 @@ await Effect.runPromise(program); ); const registry = credentials.proxyEndpoint.replace(/^https?:\/\//, ""); - yield* materializeDockerfile(dockerfile, contextDir); - yield* writeContextFiles(contextDir, [ - { path: "index.mjs", content: code }, - ]); + yield* docker.materialize({ + context: contextDir, + dockerfile: dockerfile, + files: [{ path: "index.mjs", content: code }], + }); yield* docker.image.build({ tag: imageUri, context: contextDir, diff --git a/packages/alchemy/src/Bundle/Docker.ts b/packages/alchemy/src/Bundle/Docker.ts deleted file mode 100644 index 2ac4966178..0000000000 --- a/packages/alchemy/src/Bundle/Docker.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; - -export const materializeDockerfile = Effect.fn(function* ( - dockerfile: string, - dir: string, -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(dir, { recursive: true }); - const target = path.join(dir, "Dockerfile"); - yield* fs.writeFileString(target, dockerfile); - return target; -}); - -export const writeContextFiles = Effect.fn(function* ( - dir: string, - files: ReadonlyArray<{ - readonly path: string; - readonly content: string | Uint8Array; - }>, -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - for (const file of files) { - const fullPath = path.join(dir, file.path); - yield* fs.makeDirectory(path.dirname(fullPath), { recursive: true }); - if (typeof file.content === "string") { - yield* fs.writeFileString(fullPath, file.content); - } else { - yield* fs.writeFile(fullPath, file.content); - } - } -}); diff --git a/packages/alchemy/src/Bundle/index.ts b/packages/alchemy/src/Bundle/index.ts index fa3f10ece1..7edc386096 100644 --- a/packages/alchemy/src/Bundle/index.ts +++ b/packages/alchemy/src/Bundle/index.ts @@ -1,7 +1,6 @@ export * from "./Bundle.ts"; export * from "./BundleAnalyzerPlugin.ts"; export * from "./InstalledPackages.ts"; -export * from "./Docker.ts"; export * from "./PurePlugin.ts"; export * from "./RawPlugin.ts"; export * from "./TempRoot.ts"; diff --git a/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts b/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts index 9347c8fe6f..0aacb684b9 100644 --- a/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts @@ -5,10 +5,6 @@ import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import { Unowned } from "../../AdoptPolicy.ts"; import { AlchemyContext } from "../../AlchemyContext.ts"; -import { - materializeDockerfile, - writeContextFiles, -} from "../../Bundle/Docker.ts"; import { getStableContextDir } from "../../Bundle/TempRoot.ts"; import { hashDirectory } from "../../Command/Memo.ts"; import { deepEqual, isResolved } from "../../Diff.ts"; @@ -262,18 +258,14 @@ export const LiveContainerProvider = () => props.external, props.autoInstallExternals, ); - yield* materializeDockerfile(finalDockerfile, contextDir); - yield* writeContextFiles( - contextDir, - build.files.map((f, i) => ({ - // Keep the entry rename to `index.mjs` so the Dockerfile - // ENTRYPOINT (`ENTRYPOINT ["bun", "/app/index.mjs"]`) stays - // valid; preserve rolldown-assigned fileNames for every other - // chunk so intra-bundle relative imports resolve at runtime. + yield* docker.materialize({ + context: contextDir, + dockerfile: finalDockerfile, + files: build.files.map((f, i) => ({ path: i === 0 ? "index.mjs" : f.path, content: f.content, })), - ); + }); yield* docker.image.build({ tag: imageRef, context: contextDir, diff --git a/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts b/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts index 401e4bcd5a..bec67cbdd7 100644 --- a/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts @@ -1,9 +1,10 @@ import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { AlchemyContext } from "../../AlchemyContext.ts"; import * as Artifacts from "../../Artifacts.ts"; -import { materializeDockerfile } from "../../Bundle/Docker.ts"; import { getStableContextDir } from "../../Bundle/TempRoot.ts"; import { isResolved } from "../../Diff.ts"; +import { Docker } from "../../Docker/DockerClient.ts"; import * as RpcProvider from "../../Local/RpcProvider.ts"; import { sha256Object } from "../../Util/sha256.ts"; import { normalizeNulls } from "../../Util/stable.ts"; @@ -41,6 +42,8 @@ export const LocalContainerProvider = () => LOCAL_ENTRY_URL, Effect.gen(function* () { const { dotAlchemy } = yield* AlchemyContext; + const docker = yield* Docker; + const path = yield* Path.Path; // Bundle the container entrypoint and write it (plus the generated // Dockerfile) into a stable build context directory. `Docker.build` in @@ -68,7 +71,7 @@ export const LocalContainerProvider = () => news.external, news.autoInstallExternals, ); - const [bundle, dockerfile] = yield* Effect.all( + const [bundle] = yield* Effect.all( [ bundleContainerProgram({ id, @@ -79,13 +82,17 @@ export const LocalContainerProvider = () => external: news.external, outdir: context, }), - materializeDockerfile(dockerfileContent, context), + docker.materialize({ + context: context, + dockerfile: dockerfileContent, + files: [], + }), ], { concurrency: "unbounded" }, ); return { context, - dockerfile, + dockerfile: path.join(context, "Dockerfile"), hash: yield* sha256Object({ bundle: bundle.hash, dockerfileContent, diff --git a/packages/alchemy/src/Cloudflare/Local.ts b/packages/alchemy/src/Cloudflare/Local.ts index 7571d60a4b..18cc829dbe 100644 --- a/packages/alchemy/src/Cloudflare/Local.ts +++ b/packages/alchemy/src/Cloudflare/Local.ts @@ -1,4 +1,5 @@ import * as Layer from "effect/Layer"; +import { DockerLive } from "../Docker/DockerClient.ts"; import * as RpcServer from "../Local/RpcServer.ts"; import { CloudflareAuth } from "./Auth/AuthProvider.ts"; import * as CloudflareEnvironment from "./CloudflareEnvironment.ts"; @@ -25,5 +26,6 @@ Layer.mergeAll( ).pipe( Layer.provide(localRuntimeServices()), Layer.provide(cloudflareServices), + Layer.provide(DockerLive), RpcServer.launch, ); diff --git a/packages/alchemy/src/Docker/DockerClient.ts b/packages/alchemy/src/Docker/DockerClient.ts index 6097bbf3e1..60d67ad0d3 100644 --- a/packages/alchemy/src/Docker/DockerClient.ts +++ b/packages/alchemy/src/Docker/DockerClient.ts @@ -20,6 +20,14 @@ export class Docker extends Context.Service< readonly run: ( args: Array, ) => Effect.Effect; + readonly materialize: (options: { + context: string; + dockerfile: string; + files: ReadonlyArray<{ + path: string; + content: string | Uint8Array; + }>; + }) => Effect.Effect; readonly container: { readonly create: (options: { name: string; @@ -354,6 +362,27 @@ export const DockerLive = Layer.effect( return Docker.of({ run, + materialize: Effect.fn((options) => + Effect.forEach( + [ + ...options.files, + { path: "Dockerfile", content: options.dockerfile }, + ], + (file) => { + const fullPath = path.join(options.context, file.path); + return fs + .makeDirectory(path.dirname(fullPath), { recursive: true }) + .pipe( + Effect.andThen( + typeof file.content === "string" + ? fs.writeFileString(fullPath, file.content) + : fs.writeFile(fullPath, file.content), + ), + ); + }, + { concurrency: "unbounded" }, + ), + ), container: { create: ({ image, env, ...options }) => run( diff --git a/packages/alchemy/test/Bundle/Docker.test.ts b/packages/alchemy/test/Bundle/Docker.test.ts index 7cd19c456c..87f5399e48 100644 --- a/packages/alchemy/test/Bundle/Docker.test.ts +++ b/packages/alchemy/test/Bundle/Docker.test.ts @@ -1,4 +1,3 @@ -import { materializeDockerfile, writeContextFiles } from "@/Bundle/Docker"; import { Docker, DockerLive } from "@/Docker"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, layer } from "@effect/vitest"; @@ -11,39 +10,50 @@ import { spawnSync } from "node:child_process"; const dockerDaemonOk = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; -layer(NodeServices.layer)("docker context helpers", (it) => { - it.effect("materializes a Dockerfile in the target directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-ctx-", - }); - const ctx = path.join(root, "ctx"); - const dockerfile = yield* materializeDockerfile("FROM scratch\n", ctx); - expect(dockerfile).toBe(path.join(ctx, "Dockerfile")); - expect(yield* fs.exists(dockerfile)).toBe(true); - expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n"); - }), - ); +layer(Layer.provideMerge(DockerLive, NodeServices.layer))( + "docker context helpers", + (it) => { + it.effect("materializes a Dockerfile in the target directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-ctx-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: "FROM scratch\n", + files: [], + }); + const dockerfile = path.join(ctx, "Dockerfile"); + expect(yield* fs.exists(dockerfile)).toBe(true); + expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n"); + }), + ); - it.effect("writes nested context files", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-path-", - }); - const ctx = path.join(root, "ctx"); - yield* writeContextFiles(ctx, [ - { path: "nested/hello.txt", content: "hi" }, - ]); - expect( - yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")), - ).toBe("hi"); - }), - ); -}); + it.effect("writes nested context files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-path-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: "FROM scratch\n", + files: [{ path: "nested/hello.txt", content: "hi" }], + }); + expect( + yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")), + ).toBe("hi"); + }), + ); + }, +); layer(Layer.provideMerge(DockerLive, NodeServices.layer))( "dockerBuild", @@ -59,15 +69,16 @@ layer(Layer.provideMerge(DockerLive, NodeServices.layer))( }); const ctx = path.join(root, "ctx"); const tag = "alchemy-docker-test:minimal"; - yield* materializeDockerfile( - [ + yield* docker.materialize({ + context: ctx, + dockerfile: [ "FROM alpine:3.19", "RUN echo ok > /tmp/ok.txt", 'CMD ["cat", "/tmp/ok.txt"]', "", ].join("\n"), - ctx, - ); + files: [], + }); yield* docker.image.build({ tag, context: ctx, @@ -96,15 +107,16 @@ layer(Layer.provideMerge(DockerLive, NodeServices.layer))( }); const ctx = path.join(root, "ctx"); const tag = "alchemy-docker-test:args"; - yield* materializeDockerfile( - [ + yield* docker.materialize({ + context: ctx, + dockerfile: [ "FROM alpine:3.19", "ARG FOO=default", 'RUN echo "$FOO" > /out.txt', "", ].join("\n"), - ctx, - ); + files: [], + }); yield* docker.image.build({ tag, context: ctx, @@ -141,8 +153,9 @@ layer(Layer.provideMerge(DockerLive, NodeServices.layer))( }); const ctx = path.join(root, "ctx"); const tag = "alchemy-docker-test:target"; - yield* materializeDockerfile( - [ + yield* docker.materialize({ + context: ctx, + dockerfile: [ "FROM alpine:3.19 AS base", "RUN echo base > /stage.txt", "", @@ -150,8 +163,8 @@ layer(Layer.provideMerge(DockerLive, NodeServices.layer))( "RUN echo secondary > /stage.txt", "", ].join("\n"), - ctx, - ); + files: [], + }); yield* docker.image.build({ tag, context: ctx, From e10aba69b053fc47fde8ef00b963eaf41211977b Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 18:06:42 -0400 Subject: [PATCH 09/25] remove leftover helpers --- packages/alchemy/src/AWS/ECS/Task.ts | 2 +- packages/alchemy/src/AWS/Providers.ts | 2 +- .../Cloudflare/Container/ContainerProvider.ts | 2 +- .../Container/LocalContainerProvider.ts | 2 +- packages/alchemy/src/Cloudflare/Local.ts | 2 +- packages/alchemy/src/Cloudflare/Providers.ts | 2 +- packages/alchemy/src/Docker/Container.ts | 22 ++++-- .../src/Docker/{DockerClient.ts => Docker.ts} | 25 ++++--- packages/alchemy/src/Docker/DockerApi.ts | 67 ------------------- packages/alchemy/src/Docker/Image.ts | 41 ++++++++++-- packages/alchemy/src/Docker/Network.ts | 4 +- packages/alchemy/src/Docker/Providers.ts | 2 +- packages/alchemy/src/Docker/RemoteImage.ts | 2 +- packages/alchemy/src/Docker/Volume.ts | 4 +- packages/alchemy/src/Docker/index.ts | 2 +- 15 files changed, 78 insertions(+), 103 deletions(-) rename packages/alchemy/src/Docker/{DockerClient.ts => Docker.ts} (95%) delete mode 100644 packages/alchemy/src/Docker/DockerApi.ts diff --git a/packages/alchemy/src/AWS/ECS/Task.ts b/packages/alchemy/src/AWS/ECS/Task.ts index f259f84d40..6600004f60 100644 --- a/packages/alchemy/src/AWS/ECS/Task.ts +++ b/packages/alchemy/src/AWS/ECS/Task.ts @@ -16,7 +16,7 @@ import { getStableContextDir, } from "../../Bundle/TempRoot.ts"; import { isResolved } from "../../Diff.ts"; -import { Docker } from "../../Docker/DockerClient.ts"; +import { Docker } from "../../Docker/Docker.ts"; import * as Output from "../../Output.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index fb4bb15b89..6808e9e71a 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -18,7 +18,7 @@ import * as Schedule from "effect/Schedule"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { CredentialsStoreLive } from "../Auth/Credentials.ts"; import * as Command from "../Command/index.ts"; -import { DockerLive } from "../Docker/DockerClient.ts"; +import { DockerLive } from "../Docker/Docker.ts"; import { KeyPair, KeyPairProvider } from "../KeyPair.ts"; import * as Provider from "../Provider.ts"; import { Random, RandomProvider } from "../Random.ts"; diff --git a/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts b/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts index 0aacb684b9..7f1ec2b8ba 100644 --- a/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Container/ContainerProvider.ts @@ -8,7 +8,7 @@ import { AlchemyContext } from "../../AlchemyContext.ts"; import { getStableContextDir } from "../../Bundle/TempRoot.ts"; import { hashDirectory } from "../../Command/Memo.ts"; import { deepEqual, isResolved } from "../../Diff.ts"; -import { Docker } from "../../Docker/DockerClient.ts"; +import { Docker } from "../../Docker/Docker.ts"; import * as Provider from "../../Provider.ts"; import { type ResourceBinding } from "../../Resource.ts"; import { sha256Object } from "../../Util/sha256.ts"; diff --git a/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts b/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts index bec67cbdd7..eba625520b 100644 --- a/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Container/LocalContainerProvider.ts @@ -4,7 +4,7 @@ import { AlchemyContext } from "../../AlchemyContext.ts"; import * as Artifacts from "../../Artifacts.ts"; import { getStableContextDir } from "../../Bundle/TempRoot.ts"; import { isResolved } from "../../Diff.ts"; -import { Docker } from "../../Docker/DockerClient.ts"; +import { Docker } from "../../Docker/Docker.ts"; import * as RpcProvider from "../../Local/RpcProvider.ts"; import { sha256Object } from "../../Util/sha256.ts"; import { normalizeNulls } from "../../Util/stable.ts"; diff --git a/packages/alchemy/src/Cloudflare/Local.ts b/packages/alchemy/src/Cloudflare/Local.ts index 18cc829dbe..679186e781 100644 --- a/packages/alchemy/src/Cloudflare/Local.ts +++ b/packages/alchemy/src/Cloudflare/Local.ts @@ -1,5 +1,5 @@ import * as Layer from "effect/Layer"; -import { DockerLive } from "../Docker/DockerClient.ts"; +import { DockerLive } from "../Docker/Docker.ts"; import * as RpcServer from "../Local/RpcServer.ts"; import { CloudflareAuth } from "./Auth/AuthProvider.ts"; import * as CloudflareEnvironment from "./CloudflareEnvironment.ts"; diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index faa97ada6b..6e6942499a 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -8,7 +8,7 @@ import * as Schedule from "effect/Schedule"; import { CredentialsStoreLive } from "../Auth/Credentials.ts"; import { ProfileLive } from "../Auth/Profile.ts"; import * as Command from "../Command/index.ts"; -import { DockerLive } from "../Docker/DockerClient.ts"; +import { DockerLive } from "../Docker/Docker.ts"; import { KeyPair, KeyPairProvider } from "../KeyPair.ts"; import * as Provider from "../Provider.ts"; import { Random, RandomProvider } from "../Random.ts"; diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index 8447093a61..c59e838737 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -8,7 +8,7 @@ import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./DockerClient.ts"; +import { Docker } from "./Docker.ts"; export interface ContainerProps { /** Image reference or Docker image resource. */ @@ -101,6 +101,11 @@ export interface Container extends Resource< createdAt: number; /** Image reference used to create the container. */ imageRef: string; + /** + * Map of internal container ports to their bound host ports. + * Format: `"80/tcp" -> 8080`. + */ + ports: Record; } > {} @@ -163,7 +168,7 @@ export const ContainerProvider = () => const docker = yield* Docker; const reconcileNetworks = Effect.fn(function* ( - live: Docker.ContainerInfo, + live: Docker.Container, news: ContainerProps, ) { const connect = new Map(); @@ -341,7 +346,7 @@ const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) => ); const toContainerAttributes = ( - info: Docker.ContainerInfo, + info: Docker.Container, imageRef: string, ): Container["Attributes"] => ({ id: info.Id, @@ -349,9 +354,18 @@ const toContainerAttributes = ( state: info.State.Status, createdAt: Date.parse(info.Created) || Date.now(), imageRef, + ports: Object.fromEntries( + Object.entries({ + ...info.NetworkSettings.Ports, + ...info.HostConfig.PortBindings, + }).flatMap(([internal, bindings]) => { + if (!bindings?.[0]?.HostPort) return []; + return [[internal, Number.parseInt(bindings[0].HostPort, 10)]]; + }), + ), }); -const infoName = (info: Docker.ContainerInfo) => { +const infoName = (info: Docker.Container) => { const name = info.Name; return typeof name === "string" ? name.replace(/^\//, "") : info.Id; }; diff --git a/packages/alchemy/src/Docker/DockerClient.ts b/packages/alchemy/src/Docker/Docker.ts similarity index 95% rename from packages/alchemy/src/Docker/DockerClient.ts rename to packages/alchemy/src/Docker/Docker.ts index 60d67ad0d3..9ffe74ca37 100644 --- a/packages/alchemy/src/Docker/DockerClient.ts +++ b/packages/alchemy/src/Docker/Docker.ts @@ -47,7 +47,7 @@ export class Docker extends Context.Service< }) => Effect.Effect; readonly inspect: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( name: string, force?: boolean, @@ -89,7 +89,7 @@ export class Docker extends Context.Service< ) => Effect.Effect; readonly inspect: ( ref: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( ref: string | Array, force?: boolean, @@ -107,7 +107,7 @@ export class Docker extends Context.Service< ) => Effect.Effect; readonly inspect: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect; }; readonly network: { readonly create: (options: { @@ -127,7 +127,7 @@ export class Docker extends Context.Service< }) => Effect.Effect; readonly inspect: ( name: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( id: string, ) => Effect.Effect; @@ -145,7 +145,7 @@ export declare namespace Docker { | "exited" | "dead"; - export interface ContainerInfo { + export interface Container { Id: string; Name?: string; State: { Status: ContainerStatus }; @@ -190,13 +190,13 @@ export declare namespace Docker { }; } - export interface InspectedImage { + export interface Image { Id: string; Created?: string; RepoTags?: string[] | null; RepoDigests?: string[] | null; } - export interface InspectedVolume { + export interface Volume { CreatedAt: string; Driver: string; Labels: Record | null; @@ -205,7 +205,7 @@ export declare namespace Docker { Options: Record | null; Scope: string; } - export interface InspectedNetwork { + export interface Network { Name: string; Id: string; Created: string; @@ -399,7 +399,7 @@ export const DockerLive = Layer.effect( env, ), inspect: (name) => - runInspect(["container", "inspect", name]), + runInspect(["container", "inspect", name]), remove: (name, force) => run(["container", "rm", name, ...(force ? ["-f"] : [])]), start: (name) => run(["container", "start", name]), @@ -421,8 +421,7 @@ export const DockerLive = Layer.effect( ref, ...(platform ? ["--platform", platform] : []), ]), - inspect: (ref) => - runInspect(["image", "inspect", ref]), + inspect: (ref) => runInspect(["image", "inspect", ref]), remove: (ref, force) => run([ "image", @@ -468,7 +467,7 @@ export const DockerLive = Layer.effect( create: (options) => run(["volume", "create", ...argsFrom(options)]), remove: (name) => run(["volume", "rm", name]), inspect: (name) => - runInspect(["volume", "inspect", name]), + runInspect(["volume", "inspect", name]), }, network: { create: ({ name, driver, ipv6, label }) => @@ -489,7 +488,7 @@ export const DockerLive = Layer.effect( disconnect: ({ network, container }) => run(["network", "disconnect", network, container]), inspect: (name) => - runInspect(["network", "inspect", name]), + runInspect(["network", "inspect", name]), remove: (id) => run(["network", "rm", id]), }, }); diff --git a/packages/alchemy/src/Docker/DockerApi.ts b/packages/alchemy/src/Docker/DockerApi.ts deleted file mode 100644 index bfaa80919c..0000000000 --- a/packages/alchemy/src/Docker/DockerApi.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Docker } from "./DockerClient.ts"; - -export interface ContainerRuntimeInfo { - /** - * Map of internal container ports to their bound host ports. - * Format: `"80/tcp" -> 8080`. - */ - ports: Record; -} - -export const toRuntimeInfo = ( - info: Docker.ContainerInfo, -): ContainerRuntimeInfo => { - const ports: Record = {}; - - for (const [internal, bindings] of Object.entries( - info.NetworkSettings.Ports ?? {}, - )) { - const hostPort = bindings?.[0]?.HostPort; - if (hostPort) ports[internal] = Number.parseInt(hostPort, 10); - } - - for (const [internal, bindings] of Object.entries( - info.HostConfig.PortBindings ?? {}, - )) { - const hostPort = bindings?.[0]?.HostPort; - if (hostPort && !(internal in ports)) { - ports[internal] = Number.parseInt(hostPort, 10); - } - } - - return { ports }; -}; - -export const parseRepoDigest = ( - imageRef: string, - output: string, -): string | undefined => { - const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output); - if (!match) return undefined; - return `${repositoryFromImageRef(imageRef)}@${match[1]}`; -}; - -export const repositoryFromImageRef = (imageRef: string): string => { - const withoutDigest = imageRef.includes("@") - ? imageRef.slice(0, imageRef.indexOf("@")) - : imageRef; - const tagSeparator = withoutDigest.lastIndexOf(":"); - const pathSeparator = withoutDigest.lastIndexOf("/"); - return tagSeparator > pathSeparator - ? withoutDigest.slice(0, tagSeparator) - : withoutDigest; -}; - -export const withRegistryHost = ( - imageRef: string, - registry: { server: string }, -): string => { - const registryHost = registry.server.replace(/\/$/, ""); - const firstSegment = imageRef.split("/")[0]; - const hasRegistryPrefix = - imageRef.includes("/") && - (firstSegment.includes(".") || - firstSegment.includes(":") || - firstSegment === "localhost"); - return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`; -}; diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 4cdda35e57..1cb7f62b9f 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -7,12 +7,7 @@ import { deepEqual, isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { - parseRepoDigest, - repositoryFromImageRef, - withRegistryHost, -} from "./DockerApi.ts"; -import { Docker } from "./DockerClient.ts"; +import { Docker } from "./Docker.ts"; export interface DockerBuildOptions { /** @@ -365,3 +360,37 @@ const comparableProps = (props: ImageProps | undefined) => : undefined, } : undefined; + +const parseRepoDigest = ( + imageRef: string, + output: string, +): string | undefined => { + const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output); + if (!match) return undefined; + return `${repositoryFromImageRef(imageRef)}@${match[1]}`; +}; + +const repositoryFromImageRef = (imageRef: string): string => { + const withoutDigest = imageRef.includes("@") + ? imageRef.slice(0, imageRef.indexOf("@")) + : imageRef; + const tagSeparator = withoutDigest.lastIndexOf(":"); + const pathSeparator = withoutDigest.lastIndexOf("/"); + return tagSeparator > pathSeparator + ? withoutDigest.slice(0, tagSeparator) + : withoutDigest; +}; + +const withRegistryHost = ( + imageRef: string, + registry: { server: string }, +): string => { + const registryHost = registry.server.replace(/\/$/, ""); + const firstSegment = imageRef.split("/")[0]; + const hasRegistryPrefix = + imageRef.includes("/") && + (firstSegment.includes(".") || + firstSegment.includes(":") || + firstSegment === "localhost"); + return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`; +}; diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index cf158a52d3..9474d2679e 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -6,7 +6,7 @@ import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./DockerClient.ts"; +import { Docker } from "./Docker.ts"; export interface NetworkProps { /** @@ -164,7 +164,7 @@ const makeNetworkArgs = ( ); export const toNetworkAttributes = ( - info: Docker.InspectedNetwork, + info: Docker.Network, ): Network["Attributes"] => ({ id: info.Id, name: info.Name, diff --git a/packages/alchemy/src/Docker/Providers.ts b/packages/alchemy/src/Docker/Providers.ts index 955dd578ac..8c98e106a2 100644 --- a/packages/alchemy/src/Docker/Providers.ts +++ b/packages/alchemy/src/Docker/Providers.ts @@ -1,7 +1,7 @@ import * as Layer from "effect/Layer"; import * as Provider from "../Provider.ts"; import { Container, ContainerProvider } from "./Container.ts"; -import { DockerLive } from "./DockerClient.ts"; +import { DockerLive } from "./Docker.ts"; import { Image, ImageProvider } from "./Image.ts"; import { Network, NetworkProvider } from "./Network.ts"; import { RemoteImage, RemoteImageProvider } from "./RemoteImage.ts"; diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index 78b1d1c328..b928497277 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./DockerClient.ts"; +import { Docker } from "./Docker.ts"; export interface RemoteImageProps { /** Docker image name, without tag. */ diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index dca0e75690..df44785357 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -6,7 +6,7 @@ import { isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./DockerClient.ts"; +import { Docker } from "./Docker.ts"; export interface VolumeLabel { /** Label name. */ @@ -164,7 +164,7 @@ const makeVolumeArgs = (id: string, props: VolumeProps, instanceId: string) => ); export const toVolumeAttributes = ( - info: Docker.InspectedVolume, + info: Docker.Volume, ): Volume["Attributes"] => ({ id: info.Name, name: info.Name, diff --git a/packages/alchemy/src/Docker/index.ts b/packages/alchemy/src/Docker/index.ts index 11f27256f4..f4b52736fc 100644 --- a/packages/alchemy/src/Docker/index.ts +++ b/packages/alchemy/src/Docker/index.ts @@ -1,5 +1,5 @@ export * from "./Container.ts"; -export * from "./DockerClient.ts"; +export * from "./Docker.ts"; export * from "./Image.ts"; export * from "./Network.ts"; export * from "./Providers.ts"; From a7c31259ed5c22d88c17a0589c4b9b7c2c3c44e6 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 18:14:26 -0400 Subject: [PATCH 10/25] progress --- packages/alchemy/src/Docker/Docker.ts | 22 ++++++++++++---------- packages/alchemy/src/Docker/Image.ts | 20 ++++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/alchemy/src/Docker/Docker.ts b/packages/alchemy/src/Docker/Docker.ts index 9ffe74ca37..af76084466 100644 --- a/packages/alchemy/src/Docker/Docker.ts +++ b/packages/alchemy/src/Docker/Docker.ts @@ -448,18 +448,20 @@ export const DockerLive = Layer.effect( const dir = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-docker-", }); - yield* fs.writeFileString( - path.join(dir, "config.json"), - JSON.stringify({ + const config = yield* Effect.sync(() => { + const password = Redacted.isRedacted(credentials.password) + ? Redacted.value(credentials.password) + : credentials.password; + const auth = Buffer.from( + `${credentials.username}:${password}`, + ).toString("base64"); + return JSON.stringify({ auths: { - [credentials.server]: { - auth: Buffer.from( - `${credentials.username}:${Redacted.isRedacted(credentials.password) ? Redacted.value(credentials.password) : credentials.password}`, - ).toString("base64"), - }, + [credentials.server]: { auth }, }, - }), - ); + }); + }); + yield* fs.writeFileString(path.join(dir, "config.json"), config); return yield* run(["push", ref], { DOCKER_CONFIG: dir }); }, Effect.scoped), }, diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 1cb7f62b9f..a25a679a3e 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -78,14 +78,13 @@ export interface Image extends Resource< "Docker.Image", ImageProps, { - kind: "Image"; /** Image repository/name without tag. */ name: string; /** Final image reference. Includes registry host when pushed there. */ imageRef: string; /** Local image id after build/tag when available. */ imageId?: string; - /** Registry repository digest after push when available. */ + /** Registry digest after push when available. */ repoDigest?: string; /** Tag used for the local image. */ tag: string; @@ -168,7 +167,6 @@ export const ImageProvider = () => ); if (!image) return undefined; return { - kind: "Image" as const, name: output?.name ?? repositoryFromImageRef(ref), imageRef: ref, imageId: image.Id, @@ -250,7 +248,6 @@ export const ImageProvider = () => } return { - kind: "Image" as const, name: repositoryFromImageRef(finalRef), imageRef: finalRef, imageId: currentImageId, @@ -260,10 +257,17 @@ export const ImageProvider = () => contextHash: nextContextHash, }; }), - delete: Effect.fn(function* () { - // Docker images are intentionally left in place. Tags and image ids are - // commonly shared by developer workflows outside Alchemy. - }), + delete: Effect.fn(({ output }) => + docker.image + .remove(output.imageRef) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + ), + ), + ), }); }), ); From 9ff3c6f0afed59744c3b092f0830bbc853a689da Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 18:31:11 -0400 Subject: [PATCH 11/25] add comments to docker client --- packages/alchemy/src/Docker/Docker.ts | 137 +++++++++++++++----------- 1 file changed, 81 insertions(+), 56 deletions(-) diff --git a/packages/alchemy/src/Docker/Docker.ts b/packages/alchemy/src/Docker/Docker.ts index af76084466..4ba91d8406 100644 --- a/packages/alchemy/src/Docker/Docker.ts +++ b/packages/alchemy/src/Docker/Docker.ts @@ -17,9 +17,11 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne export class Docker extends Context.Service< Docker, { + /** Runs a Docker command and returns the output. Use this to run a command that doesn't have a dedicated method. */ readonly run: ( args: Array, ) => Effect.Effect; + /** Writes build files and an inline Dockerfile to the given context directory. */ readonly materialize: (options: { context: string; dockerfile: string; @@ -29,6 +31,7 @@ export class Docker extends Context.Service< }>; }) => Effect.Effect; readonly container: { + /** Creates a new container. */ readonly create: (options: { name: string; image: string; @@ -45,21 +48,26 @@ export class Docker extends Context.Service< p: Array | undefined; command: Array | undefined; }) => Effect.Effect; + /** Inspects a container. */ readonly inspect: ( name: string, ) => Effect.Effect; + /** Removes a container. */ readonly remove: ( name: string, force?: boolean, ) => Effect.Effect; + /** Starts a container. */ readonly start: ( name: string, ) => Effect.Effect; + /** Stops a container. */ readonly stop: ( name: string, ) => Effect.Effect; }; readonly image: { + /** Builds a new image. */ readonly build: (options: { context: string; tag: string; @@ -71,10 +79,12 @@ export class Docker extends Context.Service< "cache-to"?: Array; args?: Array; }) => Effect.Effect; + /** Pulls an image. */ readonly pull: ( ref: string, platform?: string, ) => Effect.Effect; + /** Pushes an image to a registry. */ readonly push: ( ref: string, credentials: { @@ -83,57 +93,68 @@ export class Docker extends Context.Service< password: string | Redacted.Redacted; }, ) => Effect.Effect; + /** Tags an image. */ readonly tag: ( source: string, target: string, ) => Effect.Effect; + /** Inspects an image. */ readonly inspect: ( ref: string, ) => Effect.Effect; + /** Removes an image. */ readonly remove: ( ref: string | Array, force?: boolean, ) => Effect.Effect; }; readonly volume: { + /** Creates a new volume. */ readonly create: (options: { name: string; driver?: string; opt?: Record; label?: Record; }) => Effect.Effect; + /** Removes a volume. */ readonly remove: ( name: string, ) => Effect.Effect; + /** Inspects a volume. */ readonly inspect: ( name: string, ) => Effect.Effect; }; readonly network: { + /** Creates a new network. */ readonly create: (options: { name: string; driver: string; ipv6?: boolean; label?: Record; }) => Effect.Effect; + /** Connects a container to a network. */ readonly connect: (options: { network: string; container: string; alias?: string[]; }) => Effect.Effect; + /** Disconnects a container from a network. */ readonly disconnect: (options: { network: string; container: string; }) => Effect.Effect; + /** Inspects a network. */ readonly inspect: ( name: string, ) => Effect.Effect; + /** Removes a network. */ readonly remove: ( id: string, ) => Effect.Effect; }; } ->()("@alchemy/docker/client") {} +>()("@alchemy/Docker") {} export declare namespace Docker { export type ContainerStatus = @@ -196,6 +217,7 @@ export declare namespace Docker { RepoTags?: string[] | null; RepoDigests?: string[] | null; } + export interface Volume { CreatedAt: string; Driver: string; @@ -205,6 +227,7 @@ export declare namespace Docker { Options: Record | null; Scope: string; } + export interface Network { Name: string; Id: string; @@ -301,23 +324,6 @@ export const DockerLive = Layer.effect( Effect.scoped, ); - const systemError = (input: { - _tag: SystemErrorTag; - args: Array; - description?: string; - cause?: unknown; - }) => - new PlatformError( - new SystemError({ - _tag: input._tag, - module: "Docker", - method: input.args.slice(0, 2).join("."), - pathOrDescriptor: input.args[2], - description: input.description, - cause: input.cause, - }), - ); - const runInspect = (args: Array) => run(args).pipe( Effect.map((result) => { @@ -326,40 +332,6 @@ export const DockerLive = Layer.effect( }), ); - const argsFrom = ( - options: Record< - string, - | boolean - | string - | number - | undefined - | Record - | Array - >, - ) => { - const args: Array = []; - for (const [key, value] of Object.entries(options)) { - if (!value) continue; - const prefix = key.length > 1 ? `--${key}` : `-${key}`; - if (value === true) { - args.push(prefix); - } else if (typeof value === "string") { - args.push(prefix, value); - } else if (typeof value === "number") { - args.push(prefix, String(value)); - } else if (Array.isArray(value)) { - for (const item of value) { - args.push(prefix, item); - } - } else if (value !== null && typeof value === "object") { - for (const [k, v] of Object.entries(value)) { - args.push(prefix, `${k}=${v}`); - } - } - } - return args; - }; - return Docker.of({ run, materialize: Effect.fn((options) => @@ -389,7 +361,7 @@ export const DockerLive = Layer.effect( [ "container", "create", - ...argsFrom({ + ...formatArgs({ ...options, env: env ? Object.keys(env) : undefined, }), @@ -411,7 +383,7 @@ export const DockerLive = Layer.effect( "image", "build", context, - ...argsFrom(options), + ...formatArgs(options), ...(args ?? []), ]), pull: (ref, platform) => @@ -466,7 +438,7 @@ export const DockerLive = Layer.effect( }, Effect.scoped), }, volume: { - create: (options) => run(["volume", "create", ...argsFrom(options)]), + create: (options) => run(["volume", "create", ...formatArgs(options)]), remove: (name) => run(["volume", "rm", name]), inspect: (name) => runInspect(["volume", "inspect", name]), @@ -477,7 +449,7 @@ export const DockerLive = Layer.effect( "network", "create", name, - ...argsFrom({ driver, ipv6, label }), + ...formatArgs({ driver, ipv6, label }), ]), connect: ({ network, container, alias }) => run([ @@ -496,3 +468,56 @@ export const DockerLive = Layer.effect( }); }), ); + +/** Constructs a PlatformError from a command execution result. */ +const systemError = (input: { + _tag: SystemErrorTag; + args: Array; + description?: string; + cause?: unknown; +}) => + new PlatformError( + new SystemError({ + _tag: input._tag, + module: "Docker", + method: input.args.slice(0, 2).join("."), + pathOrDescriptor: input.args.join(" "), + description: input.description, + cause: input.cause, + }), + ); + +/** Formats a set of options into a list of command-line arguments. */ +const formatArgs = ( + options: Record< + string, + | boolean + | string + | number + | undefined + | Record + | Array + >, +) => { + const args: Array = []; + for (const [key, value] of Object.entries(options)) { + if (!value) continue; + const prefix = key.length > 1 ? `--${key}` : `-${key}`; + if (value === true) { + args.push(prefix); + } else if (typeof value === "string") { + args.push(prefix, value); + } else if (typeof value === "number") { + args.push(prefix, String(value)); + } else if (Array.isArray(value)) { + for (const item of value) { + args.push(prefix, item); + } + } else if (value !== null && typeof value === "object") { + for (const [k, v] of Object.entries(value)) { + args.push(prefix, `${k}=${v}`); + } + } + } + return args; +}; From 57fc5932fc3f88ee6f77a6288790759b3918c273 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 18:38:15 -0400 Subject: [PATCH 12/25] fix any in providers --- packages/alchemy/src/Docker/Image.ts | 69 +++++++++++++++------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index a25a679a3e..3c6f70030e 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -149,7 +149,42 @@ export const ImageProvider = () => Provider.effect( Image, Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const docker = yield* Docker; + const context = yield* Effect.context< + FileSystem.FileSystem | Path.Path + >(); + + const contextHash = Effect.fn(function* (props: ImageProps) { + if (!hasBuild(props)) return undefined; + const cwd = yield* Effect.sync(() => process.cwd()); + return yield* hashDirectory({ + cwd: props.build.context ?? cwd, + memo: props.build.memo, + }); + }, Effect.provide(context)); + + const resolveBuildPaths = Effect.fn(function* ( + build: DockerBuildOptions, + ) { + const cwd = yield* Effect.sync(() => process.cwd()); + const context = path.resolve(build.context ?? cwd); + const dockerfile = build.dockerfile + ? path.isAbsolute(build.dockerfile) + ? build.dockerfile + : path.resolve(context, build.dockerfile) + : path.resolve(context, "Dockerfile"); + if (!(yield* fs.exists(context))) { + return yield* Effect.die( + `Docker build context does not exist: ${context}`, + ); + } + if (!(yield* fs.exists(dockerfile))) { + return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`); + } + return { context, dockerfile }; + }); return Image.Provider.of({ list: () => Effect.succeed([]), @@ -309,11 +344,7 @@ export const desiredImageRef = (id: string, props: ImageProps): string => { * instance id) just like other resources, then carried back on `props.name` so * the synchronous ref helpers stay deterministic across reconcile/diff/read. */ -const withResolvedName = ( - id: string, - props: ImageProps, - instanceId: string, -): Effect.Effect => +const withResolvedName = (id: string, props: ImageProps, instanceId: string) => hasBuild(props) && props.name === undefined ? createPhysicalName({ id, @@ -323,34 +354,6 @@ const withResolvedName = ( }).pipe(Effect.map((name): ImageProps => ({ ...props, name }))) : Effect.succeed(props); -const resolveBuildPaths = Effect.fn(function* (build: DockerBuildOptions) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* Effect.sync(() => process.cwd()); - const context = path.resolve(build.context ?? cwd); - const dockerfile = build.dockerfile - ? path.isAbsolute(build.dockerfile) - ? build.dockerfile - : path.resolve(context, build.dockerfile) - : path.resolve(context, "Dockerfile"); - if (!(yield* fs.exists(context))) { - return yield* Effect.die(`Docker build context does not exist: ${context}`); - } - if (!(yield* fs.exists(dockerfile))) { - return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`); - } - return { context, dockerfile }; -}); - -const contextHash = Effect.fn(function* (props: ImageProps) { - if (!hasBuild(props)) return undefined; - const cwd = yield* Effect.sync(() => process.cwd()); - return yield* hashDirectory({ - cwd: props.build.context ?? cwd, - memo: props.build.memo, - }); -}); - const comparableProps = (props: ImageProps | undefined) => props ? { From ef3b9311b6ea3197b3c6de2fb95d6842b32e1517 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 19:19:03 -0400 Subject: [PATCH 13/25] allow RemoteImage to re-tag and push; remove remote pull from Image --- packages/alchemy/src/Docker/Image.ts | 196 ++++-------------- packages/alchemy/src/Docker/Registry.ts | 63 ++++++ packages/alchemy/src/Docker/RemoteImage.ts | 129 ++++++++++-- packages/alchemy/src/Docker/index.ts | 1 + .../test/Docker/Docker.integration.test.ts | 88 ++++---- 5 files changed, 269 insertions(+), 208 deletions(-) create mode 100644 packages/alchemy/src/Docker/Registry.ts diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 3c6f70030e..06c55026b3 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -1,13 +1,19 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Redacted from "effect/Redacted"; import { hashDirectory, type MemoOptions } from "../Command/Memo.ts"; import { deepEqual, isResolved } from "../Diff.ts"; import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { Docker } from "./Docker.ts"; +import { + type ImageRegistry, + parseCreatedAt, + parseRepoDigest, + repositoryFromImageRef, + withRegistryHost, +} from "./Registry.ts"; export interface DockerBuildOptions { /** @@ -38,41 +44,18 @@ export interface DockerBuildOptions { memo?: MemoOptions; } -export interface ImageRegistry { - /** Registry host, e.g. `ghcr.io`. */ - server: string; - /** Registry username. */ - username: string; - /** Registry password. Use `Redacted.make(...)` or `Config.redacted(...)`. */ - password: Redacted.Redacted; -} - -export type ImageSource = - | string - | { imageRef: string; name?: string; kind?: "Image" | "RemoteImage" }; - -export type ImageProps = { +export interface ImageProps { /** Image tag. @default "latest" */ tag?: string; /** Registry credentials for push. */ registry?: ImageRegistry; /** Skip registry push even when `registry` is set. @default false */ skipPush?: boolean; -} & ( - | { - /** Existing image reference or another Docker image resource. */ - image: ImageSource; - build?: never; - name?: never; - } - | { - /** Repository/name for the built image. @default Logical id */ - name?: string; - /** Docker build configuration. */ - build: DockerBuildOptions; - image?: never; - } -); + /** Repository/name for the built image. @default Logical id */ + name?: string; + /** Docker build configuration. */ + build: DockerBuildOptions; +} export interface Image extends Resource< "Docker.Image", @@ -82,28 +65,31 @@ export interface Image extends Resource< name: string; /** Final image reference. Includes registry host when pushed there. */ imageRef: string; - /** Local image id after build/tag when available. */ - imageId?: string; + /** Local image id after build/tag. */ + imageId: string; /** Registry digest after push when available. */ repoDigest?: string; /** Tag used for the local image. */ tag: string; /** Build timestamp in milliseconds since epoch. */ builtAt: number; - /** Hash of build-context files when `build` is used. */ + /** Hash of the build-context files. */ contextHash?: string; } > {} /** - * Builds, pulls, tags, and optionally pushes Docker images through the active - * Docker context. + * Builds, tags, and optionally pushes Docker images through the active Docker + * context. * * This resource uses the Docker CLI and whatever daemon or remote context the * CLI is configured to target. It is separate from `Cloudflare.Container`; * registry image references are the boundary between Docker-managed images and * cloud container platforms. * + * `Image` always builds from a Dockerfile. To pull (and optionally re-tag and + * push) an existing registry image, use `Docker.RemoteImage`. + * * @resource * * @section Building Images @@ -120,15 +106,6 @@ export interface Image extends Resource< * }); * ``` * - * @section Tagging Remote Images - * @example Pull and tag an image reference - * ```typescript - * const image = yield* Docker.Image("nginx", { - * image: "nginx:alpine", - * tag: "app-base", - * }); - * ``` - * * @section Registry Push * @example Push with Redacted credentials * ```typescript @@ -157,7 +134,6 @@ export const ImageProvider = () => >(); const contextHash = Effect.fn(function* (props: ImageProps) { - if (!hasBuild(props)) return undefined; const cwd = yield* Effect.sync(() => process.cwd()); return yield* hashDirectory({ cwd: props.build.context ?? cwd, @@ -207,7 +183,7 @@ export const ImageProvider = () => imageId: image.Id, repoDigest: output?.repoDigest, tag: output?.tag ?? olds.tag ?? "latest", - builtAt: output?.builtAt ?? Date.parse(image.Created ?? ""), + builtAt: output?.builtAt ?? parseCreatedAt(image.Created), contextHash: output?.contextHash, }; }), @@ -228,52 +204,27 @@ export const ImageProvider = () => const props = yield* withResolvedName(id, news, instanceId); const tag = props.tag ?? "latest"; const ref = localImageRef(id, props); - let finalRef = ref; - let repoDigest: string | undefined; - let nextContextHash: string | undefined; - if (hasBuild(props)) { - const paths = yield* resolveBuildPaths(props.build); - yield* session.note(`Building Docker image: ${ref}`); - yield* docker.image.build({ - tag: ref, - context: paths.context, - file: paths.dockerfile, - platform: props.build.platform, - target: props.build.target, - "build-arg": props.build.args, - "cache-from": props.build.cacheFrom, - "cache-to": props.build.cacheTo, - args: props.build.options, - }); - nextContextHash = yield* contextHash(props); - } else { - const sourceRef = imageSourceRef(props.image); - if (!isLocalImageSource(props.image)) { - const source = yield* docker.image - .inspect(sourceRef) - .pipe( - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.undefined, - ), - ); - if (!source) { - yield* session.note(`Pulling Docker image: ${sourceRef}`); - yield* docker.image.pull(sourceRef); - } - } - yield* session.note(`Tagging Docker image: ${sourceRef} -> ${ref}`); - yield* docker.image.tag(sourceRef, ref); - } + const paths = yield* resolveBuildPaths(props.build); + yield* session.note(`Building Docker image: ${ref}`); + yield* docker.image.build({ + tag: ref, + context: paths.context, + file: paths.dockerfile, + platform: props.build.platform, + target: props.build.target, + "build-arg": props.build.args, + "cache-from": props.build.cacheFrom, + "cache-to": props.build.cacheTo, + args: props.build.options, + }); + const nextContextHash = yield* contextHash(props); - // Read the freshly built/tagged image's id and creation time straight - // from Docker rather than synthesizing a wall-clock timestamp. + // Read the freshly built image's id and creation time straight from + // Docker rather than synthesizing a wall-clock timestamp. const inspected = yield* docker.image.inspect(ref); - const currentImageId = inspected?.Id; - const builtAt = Date.parse(inspected?.Created ?? ""); + let repoDigest: string | undefined; if (props.registry && !props.skipPush) { repoDigest = yield* docker.image .push(ref, props.registry) @@ -283,12 +234,12 @@ export const ImageProvider = () => } return { - name: repositoryFromImageRef(finalRef), - imageRef: finalRef, - imageId: currentImageId, + name: repositoryFromImageRef(ref), + imageRef: ref, + imageId: inspected.Id, repoDigest, tag, - builtAt, + builtAt: parseCreatedAt(inspected.Created), contextHash: nextContextHash, }; }), @@ -307,29 +258,8 @@ export const ImageProvider = () => }), ); -const imageSourceRef = (source: ImageSource): string => - typeof source === "string" ? source : source.imageRef; - -const imageSourceName = (source: ImageSource): string | undefined => - typeof source === "string" - ? repositoryFromImageRef(source) - : (source.name ?? repositoryFromImageRef(source.imageRef)); - -const isLocalImageSource = (source: ImageSource): boolean => - typeof source !== "string" && source.kind === "Image"; - -const hasBuild = ( - props: ImageProps, -): props is Extract => - "build" in props && props.build !== undefined; - -export const localImageRef = (id: string, props: ImageProps): string => { - const tag = props.tag ?? "latest"; - const name = hasBuild(props) - ? (props.name ?? id) - : (imageSourceName(props.image) ?? id); - return `${name}:${tag}`; -}; +export const localImageRef = (id: string, props: ImageProps): string => + `${props.name ?? id}:${props.tag ?? "latest"}`; export const desiredImageRef = (id: string, props: ImageProps): string => { const ref = localImageRef(id, props); @@ -345,7 +275,7 @@ export const desiredImageRef = (id: string, props: ImageProps): string => { * the synchronous ref helpers stay deterministic across reconcile/diff/read. */ const withResolvedName = (id: string, props: ImageProps, instanceId: string) => - hasBuild(props) && props.name === undefined + props.name === undefined ? createPhysicalName({ id, instanceId, @@ -367,37 +297,3 @@ const comparableProps = (props: ImageProps | undefined) => : undefined, } : undefined; - -const parseRepoDigest = ( - imageRef: string, - output: string, -): string | undefined => { - const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output); - if (!match) return undefined; - return `${repositoryFromImageRef(imageRef)}@${match[1]}`; -}; - -const repositoryFromImageRef = (imageRef: string): string => { - const withoutDigest = imageRef.includes("@") - ? imageRef.slice(0, imageRef.indexOf("@")) - : imageRef; - const tagSeparator = withoutDigest.lastIndexOf(":"); - const pathSeparator = withoutDigest.lastIndexOf("/"); - return tagSeparator > pathSeparator - ? withoutDigest.slice(0, tagSeparator) - : withoutDigest; -}; - -const withRegistryHost = ( - imageRef: string, - registry: { server: string }, -): string => { - const registryHost = registry.server.replace(/\/$/, ""); - const firstSegment = imageRef.split("/")[0]; - const hasRegistryPrefix = - imageRef.includes("/") && - (firstSegment.includes(".") || - firstSegment.includes(":") || - firstSegment === "localhost"); - return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`; -}; diff --git a/packages/alchemy/src/Docker/Registry.ts b/packages/alchemy/src/Docker/Registry.ts new file mode 100644 index 0000000000..b279ff9a70 --- /dev/null +++ b/packages/alchemy/src/Docker/Registry.ts @@ -0,0 +1,63 @@ +import type * as Redacted from "effect/Redacted"; + +export interface ImageRegistry { + /** Registry host, e.g. `ghcr.io`. */ + server: string; + /** Registry username. */ + username: string; + /** Registry password. Use `Redacted.make(...)` or `Config.redacted(...)`. */ + password: Redacted.Redacted; +} + +/** Strips the tag and digest from an image reference, leaving the repository. */ +export const repositoryFromImageRef = (imageRef: string): string => { + const withoutDigest = imageRef.includes("@") + ? imageRef.slice(0, imageRef.indexOf("@")) + : imageRef; + const tagSeparator = withoutDigest.lastIndexOf(":"); + const pathSeparator = withoutDigest.lastIndexOf("/"); + return tagSeparator > pathSeparator + ? withoutDigest.slice(0, tagSeparator) + : withoutDigest; +}; + +/** + * Prefixes an image reference with the registry host unless the reference + * already carries a registry prefix (a dotted host, a host:port, or `localhost`). + */ +export const withRegistryHost = ( + imageRef: string, + registry: { server: string }, +): string => { + const registryHost = registry.server.replace(/\/$/, ""); + const firstSegment = imageRef.split("/")[0]; + const hasRegistryPrefix = + imageRef.includes("/") && + (firstSegment.includes(".") || + firstSegment.includes(":") || + firstSegment === "localhost"); + return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`; +}; + +/** Extracts the `repository@sha256:...` digest from `docker push` output. */ +export const parseRepoDigest = ( + imageRef: string, + output: string, +): string | undefined => { + const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output); + if (!match) return undefined; + return `${repositoryFromImageRef(imageRef)}@${match[1]}`; +}; + +/** + * Parses an image's RFC 3339 `Created` timestamp into epoch milliseconds. + * + * Docker only reports `Created` when the image config carries a creation time: + * API >= 1.44 omits it, older APIs backfill the year-1 zero value + * (`0001-01-01T00:00:00Z`), and 25.0.0–25.0.3 returned an empty string. In any + * of those cases there is no real build time, so we fall back to the wall clock. + */ +export const parseCreatedAt = (created: string | undefined): number => { + const parsed = created ? Date.parse(created) : Number.NaN; + return Number.isNaN(parsed) || parsed <= 0 ? Date.now() : parsed; +}; diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index b928497277..50b3916e4a 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -3,11 +3,18 @@ import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { Docker } from "./Docker.ts"; +import { + type ImageRegistry, + parseCreatedAt, + parseRepoDigest, + repositoryFromImageRef, + withRegistryHost, +} from "./Registry.ts"; export interface RemoteImageProps { - /** Docker image name, without tag. */ + /** Docker image name to pull, without tag. */ name: string; - /** Docker image tag. @default "latest" */ + /** Docker image tag to pull. @default "latest" */ tag?: string; /** Pull for this platform. */ platform?: string; @@ -17,31 +24,55 @@ export interface RemoteImageProps { * @default true */ alwaysPull?: boolean; + /** + * Re-tag the pulled image under this repository/name. When omitted the pulled + * `name` is kept. + */ + targetName?: string; + /** + * Tag applied to the re-tagged image. + * + * @default The pulled `tag`. + */ + targetTag?: string; + /** Registry credentials. When set, the (re-tagged) image is pushed. */ + registry?: ImageRegistry; + /** + * Skip registry push even when `registry` is set. + * + * @default false + */ + skipPush?: boolean; } export interface RemoteImage extends Resource< "Docker.RemoteImage", RemoteImageProps, { - /** Full image reference. */ + /** Final image reference. Includes the registry host when pushed there. */ imageRef: string; - /** Local image id after pull when available. */ - imageId?: string; + /** Local image id after pull. */ + imageId: string; /** Pull timestamp in milliseconds since epoch. */ createdAt: number; - /** Image name. */ + /** Final image repository/name. */ name: string; - /** Image tag. */ + /** Final image tag. */ tag: string; + /** Registry digest after push when available. */ + repoDigest?: string; } > {} /** - * Pulls a remote Docker image through the active Docker context. + * Pulls a remote Docker image through the active Docker context, optionally + * re-tagging it and pushing it to a registry. * * The image is available to other Docker resources by `imageRef`. Use * `alwaysPull: false` when you want to reuse an existing tag in the configured - * Docker daemon instead of pulling on every deploy. + * Docker daemon instead of pulling on every deploy. Set `targetName`/`targetTag` + * to re-tag the pulled image, and `registry` to push it (mirroring it from a + * source registry into your own, for example). * * @resource * @@ -62,6 +93,22 @@ export interface RemoteImage extends Resource< * alwaysPull: false, * }); * ``` + * + * @section Re-tagging and Pushing + * @example Mirror a public image into your registry + * ```typescript + * const mirrored = yield* Docker.RemoteImage("nginx-mirror", { + * name: "nginx", + * tag: "alpine", + * targetName: "acme/nginx", + * targetTag: "alpine", + * registry: { + * server: "ghcr.io", + * username: "octocat", + * password: Config.redacted("GITHUB_TOKEN"), + * }, + * }); + * ``` */ export const RemoteImage = Resource("Docker.RemoteImage"); @@ -74,14 +121,15 @@ export const RemoteImageProvider = () => return RemoteImage.Provider.of({ list: () => Effect.succeed([]), read: Effect.fn(function* ({ olds, output }) { - const ref = output?.imageRef ?? remoteImageRef(olds); + const ref = output?.imageRef ?? targetImageRef(olds); return yield* docker.image.inspect(ref).pipe( Effect.map((image) => ({ imageRef: ref, imageId: image.Id, - createdAt: output?.createdAt ?? Date.parse(image.Created ?? ""), - name: olds.name, - tag: olds.tag ?? "latest", + createdAt: output?.createdAt ?? parseCreatedAt(image.Created), + name: output?.name ?? repositoryFromImageRef(ref), + tag: output?.tag ?? targetTag(olds), + repoDigest: output?.repoDigest, })), Effect.catchReason( "PlatformError", @@ -95,22 +143,44 @@ export const RemoteImageProvider = () => if ( !output || news.alwaysPull !== false || - output.imageRef !== remoteImageRef(news) + output.imageRef !== targetImageRef(news) ) { return { action: "update" }; } }), reconcile: Effect.fn(function* ({ news, session }) { - const ref = remoteImageRef(news); - yield* session.note(`Pulling Docker image: ${ref}`); - yield* docker.image.pull(ref, news.platform); - const inspected = yield* docker.image.inspect(ref); + const sourceRef = remoteImageRef(news); + yield* session.note(`Pulling Docker image: ${sourceRef}`); + yield* docker.image.pull(sourceRef, news.platform); + + const finalRef = targetImageRef(news); + if (finalRef !== sourceRef) { + yield* session.note( + `Tagging Docker image: ${sourceRef} -> ${finalRef}`, + ); + yield* docker.image.tag(sourceRef, finalRef); + } + + let repoDigest: string | undefined; + if (news.registry && !news.skipPush) { + yield* session.note(`Pushing Docker image: ${finalRef}`); + repoDigest = yield* docker.image + .push(finalRef, news.registry) + .pipe( + Effect.map((result) => + parseRepoDigest(finalRef, result.stdout), + ), + ); + } + + const inspected = yield* docker.image.inspect(finalRef); return { - imageRef: ref, + imageRef: finalRef, imageId: inspected.Id, - createdAt: Date.parse(inspected.Created ?? ""), - name: news.name, - tag: news.tag ?? "latest", + createdAt: parseCreatedAt(inspected.Created), + name: repositoryFromImageRef(finalRef), + tag: targetTag(news), + repoDigest, }; }), delete: Effect.fn(function* () { @@ -121,5 +191,20 @@ export const RemoteImageProvider = () => }), ); +/** The reference the image is pulled from. */ export const remoteImageRef = (props: RemoteImageProps): string => `${props.name}:${props.tag ?? "latest"}`; + +const targetTag = (props: RemoteImageProps): string => + props.targetTag ?? props.tag ?? "latest"; + +/** + * The final reference after re-tagging and registry-host prefixing. Equals the + * pulled reference when no re-tag/registry is configured. + */ +const targetImageRef = (props: RemoteImageProps): string => { + const local = `${props.targetName ?? props.name}:${targetTag(props)}`; + return props.registry && !props.skipPush + ? withRegistryHost(local, props.registry) + : local; +}; diff --git a/packages/alchemy/src/Docker/index.ts b/packages/alchemy/src/Docker/index.ts index f4b52736fc..2a98cf0735 100644 --- a/packages/alchemy/src/Docker/index.ts +++ b/packages/alchemy/src/Docker/index.ts @@ -3,5 +3,6 @@ export * from "./Docker.ts"; export * from "./Image.ts"; export * from "./Network.ts"; export * from "./Providers.ts"; +export type { ImageRegistry } from "./Registry.ts"; export * from "./RemoteImage.ts"; export * from "./Volume.ts"; diff --git a/packages/alchemy/test/Docker/Docker.integration.test.ts b/packages/alchemy/test/Docker/Docker.integration.test.ts index 292020fae6..91a2e59eb8 100644 --- a/packages/alchemy/test/Docker/Docker.integration.test.ts +++ b/packages/alchemy/test/Docker/Docker.integration.test.ts @@ -1,4 +1,5 @@ import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; +import { hashDirectory } from "@/Command/Memo"; import * as Docker from "@/Docker"; import * as Provider from "@/Provider"; import { inMemoryState } from "@/State"; @@ -109,38 +110,49 @@ test.provider("provider diff canaries for replacements and registry refs", () => }); expect(networkDiff).toEqual({ action: "replace", deleteFirst: true }); - const imageDiff = yield* imageProvider.diff!({ - id: "app-image", - instanceId: "instance", - olds: { - image: "acme/app:base", + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const buildDir = yield* fs.makeTempDirectory({ + prefix: "alchemy-docker-canary-", + }); + try { + yield* fs.writeFileString( + path.join(buildDir, "Dockerfile"), + "FROM scratch\n", + ); + const contextHash = yield* hashDirectory({ cwd: buildDir }); + const imageProps = { + name: "acme/app", tag: "latest", + build: { context: buildDir }, registry: { server: "ghcr.io", username: "octocat", password: Redacted.make("token"), }, - }, - news: { - image: "acme/app:base", - tag: "latest", - registry: { - server: "ghcr.io", - username: "octocat", - password: Redacted.make("token"), + }; + // No change + registry-host prefixing must not trigger a spurious update: + // desiredImageRef("acme/app:latest") resolves to "ghcr.io/acme/app:latest". + const imageDiff = yield* imageProvider.diff!({ + id: "app-image", + instanceId: "instance", + olds: imageProps, + news: imageProps, + oldBindings: [], + newBindings: [], + output: { + name: "ghcr.io/acme/app", + imageRef: "ghcr.io/acme/app:latest", + imageId: "sha256:0", + tag: "latest", + builtAt: 0, + contextHash, }, - }, - oldBindings: [], - newBindings: [], - output: { - kind: "Image", - name: "ghcr.io/acme/app", - imageRef: "ghcr.io/acme/app:latest", - tag: "latest", - builtAt: 0, - }, - }); - expect(imageDiff).toBeUndefined(); + }); + expect(imageDiff).toBeUndefined(); + } finally { + yield* fs.remove(buildDir, { recursive: true }).pipe(Effect.ignore); + } }), ); @@ -246,34 +258,38 @@ describe.sequential("Docker resources", () => { ); test.provider.skipIf(!dockerDaemonOk)( - "image string source pulls before tagging when the source tag is absent", + "remote image pulls then re-tags under a new repository", (stack) => Effect.gen(function* () { const docker = yield* Docker.Docker; - const sourceRef = "hello-world:latest"; - const targetTag = "alchemy-test-remote-source"; - const targetRef = `hello-world:${targetTag}`; + const targetName = "alchemy-test-hello"; + const targetTag = "retagged"; + const targetRef = `${targetName}:${targetTag}`; yield* docker.image - .remove([targetRef, sourceRef], true) + .remove([targetRef], true) .pipe( Effect.catchReason("PlatformError", "NotFound", () => Effect.void), ); try { const image = yield* stack.deploy( Effect.gen(function* () { - return yield* Docker.Image("remote-source-image", { - image: sourceRef, - tag: targetTag, + return yield* Docker.RemoteImage("retagged-hello", { + name: "hello-world", + tag: "latest", + targetName, + targetTag, }); }), ); expect(image.imageRef).toBe(targetRef); + expect(image.name).toBe(targetName); + expect(image.tag).toBe(targetTag); expect(image.imageId?.length).toBeGreaterThan(0); + const inspected = yield* docker.image.inspect(targetRef); + expect(inspected.Id.length).toBeGreaterThan(0); } finally { yield* stack.destroy().pipe(Effect.ignore); - yield* docker.image - .remove([targetRef, sourceRef], true) - .pipe(Effect.ignore); + yield* docker.image.remove([targetRef], true).pipe(Effect.ignore); } }), { timeout: 120000 }, From 61b2040d644015e199a3a8c71abe69d9bc4e7e0e Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 19:24:29 -0400 Subject: [PATCH 14/25] write docker build logs as session notes --- packages/alchemy/src/Docker/Docker.ts | 70 ++++++++++++++++++--------- packages/alchemy/src/Docker/Image.ts | 25 +++++----- 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/packages/alchemy/src/Docker/Docker.ts b/packages/alchemy/src/Docker/Docker.ts index 4ba91d8406..5be1555356 100644 --- a/packages/alchemy/src/Docker/Docker.ts +++ b/packages/alchemy/src/Docker/Docker.ts @@ -2,6 +2,7 @@ import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import { flow } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { @@ -10,9 +11,11 @@ import { type SystemErrorTag, } from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; +import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type { ScopedPlanStatusSession } from "../Cli/Cli.ts"; export class Docker extends Context.Service< Docker, @@ -67,18 +70,21 @@ export class Docker extends Context.Service< ) => Effect.Effect; }; readonly image: { - /** Builds a new image. */ - readonly build: (options: { - context: string; - tag: string; - file?: string; - platform?: string; - target?: string; - "build-arg"?: Record; - "cache-from"?: Array; - "cache-to"?: Array; - args?: Array; - }) => Effect.Effect; + /** Builds a new image. If a session is provided, build logs will be emitted as session notes. */ + readonly build: ( + options: { + context: string; + tag: string; + file?: string; + platform?: string; + target?: string; + "build-arg"?: Record; + "cache-from"?: Array; + "cache-to"?: Array; + args?: Array; + }, + session?: ScopedPlanStatusSession, + ) => Effect.Effect; /** Pulls an image. */ readonly pull: ( ref: string, @@ -257,7 +263,15 @@ export const DockerLive = Layer.effect( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bin = yield* DockerBin; - const run = (args: Array, env?: Record) => + const run = ( + args: Array, + env?: Record, + tap: ( + stream: Stream.Stream, + ) => Stream.Stream = Stream.tap( + Effect.logDebug, + ), + ) => ChildProcess.make(bin, args, { stdin: "ignore", stdout: "pipe", @@ -273,13 +287,13 @@ export const DockerLive = Layer.effect( exitCode: child.exitCode, stdout: child.stdout.pipe( Stream.decodeText, - Stream.tap(Effect.logDebug), + tap, Stream.mkString, Effect.map((stdout) => stdout.trim()), ), stderr: child.stderr.pipe( Stream.decodeText, - Stream.tap(Effect.logDebug), + tap, Stream.mkString, Effect.map((stderr) => stderr.trim()), ), @@ -378,14 +392,24 @@ export const DockerLive = Layer.effect( stop: (name) => run(["container", "stop", name]), }, image: { - build: ({ context, args, ...options }) => - run([ - "image", - "build", - context, - ...formatArgs(options), - ...(args ?? []), - ]), + build: ({ context, args, ...options }, session) => + run( + [ + "image", + "build", + context, + ...formatArgs(options), + ...(args ?? []), + ], + undefined, + session + ? Stream.tapSink( + Sink.make()( + flow(Stream.splitLines, Stream.runForEach(session.note)), + ), + ) + : undefined, + ), pull: (ref, platform) => run([ "image", diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 06c55026b3..1d4a829810 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -207,17 +207,20 @@ export const ImageProvider = () => const paths = yield* resolveBuildPaths(props.build); yield* session.note(`Building Docker image: ${ref}`); - yield* docker.image.build({ - tag: ref, - context: paths.context, - file: paths.dockerfile, - platform: props.build.platform, - target: props.build.target, - "build-arg": props.build.args, - "cache-from": props.build.cacheFrom, - "cache-to": props.build.cacheTo, - args: props.build.options, - }); + yield* docker.image.build( + { + tag: ref, + context: paths.context, + file: paths.dockerfile, + platform: props.build.platform, + target: props.build.target, + "build-arg": props.build.args, + "cache-from": props.build.cacheFrom, + "cache-to": props.build.cacheTo, + args: props.build.options, + }, + session, + ); const nextContextHash = yield* contextHash(props); // Read the freshly built image's id and creation time straight from From 6a8373cecfa0a9f3f6d24b58fa2970ffebe18118 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 20:07:33 -0400 Subject: [PATCH 15/25] add more tests --- packages/alchemy/src/Docker/Container.ts | 77 +-- packages/alchemy/src/Docker/Network.ts | 14 +- packages/alchemy/src/Docker/Volume.ts | 18 +- packages/alchemy/test/Bundle/Docker.test.ts | 198 -------- .../alchemy/test/Docker/Container.test.ts | 166 +++++++ .../test/Docker/Docker.integration.test.ts | 445 ------------------ packages/alchemy/test/Docker/Docker.test.ts | 162 +++++++ .../alchemy/test/Docker/DockerApi.test.ts | 200 -------- packages/alchemy/test/Docker/Image.test.ts | 198 ++++++++ packages/alchemy/test/Docker/Network.test.ts | 177 +++++++ packages/alchemy/test/Docker/Registry.test.ts | 109 +++++ .../alchemy/test/Docker/RemoteImage.test.ts | 184 ++++++++ packages/alchemy/test/Docker/Volume.test.ts | 115 +++++ 13 files changed, 1169 insertions(+), 894 deletions(-) delete mode 100644 packages/alchemy/test/Bundle/Docker.test.ts create mode 100644 packages/alchemy/test/Docker/Container.test.ts delete mode 100644 packages/alchemy/test/Docker/Docker.integration.test.ts create mode 100644 packages/alchemy/test/Docker/Docker.test.ts delete mode 100644 packages/alchemy/test/Docker/DockerApi.test.ts create mode 100644 packages/alchemy/test/Docker/Image.test.ts create mode 100644 packages/alchemy/test/Docker/Network.test.ts create mode 100644 packages/alchemy/test/Docker/Registry.test.ts create mode 100644 packages/alchemy/test/Docker/RemoteImage.test.ts create mode 100644 packages/alchemy/test/Docker/Volume.test.ts diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index c59e838737..ca0787ecb3 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -306,43 +306,46 @@ const normalizeImageRef = (image: Container.Image): string => const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) => containerName(id, news, instanceId).pipe( - Effect.map((name) => ({ - name, - image: normalizeImageRef(news.image), - command: news.command, - env: normalizeEnvironment(news.environment), - volume: news.volumes?.map( - (v) => `${v.hostPath}:${v.containerPath}${v.readOnly ? ":ro" : ""}`, - ), - p: news.ports?.map( - (port) => `${port.external}:${port.internal}/${port.protocol ?? "tcp"}`, - ), - restart: news.restart ?? "no", - rm: news.removeOnExit ?? false, - ...(news.healthcheck - ? { - "health-cmd": Array.isArray(news.healthcheck.cmd) - ? news.healthcheck.cmd.join(" ") - : news.healthcheck.cmd, - "health-interval": normalizeDuration(news.healthcheck.interval), - "health-timeout": normalizeDuration(news.healthcheck.timeout), - "health-retries": news.healthcheck.retries ?? 0, - "health-start-period": normalizeDuration( - news.healthcheck.startPeriod, - ), - "health-start-interval": normalizeDuration( - news.healthcheck.startInterval, - ), - } - : { - "health-cmd": undefined, - "health-interval": undefined, - "health-timeout": undefined, - "health-retries": undefined, - "health-start-period": undefined, - "health-start-interval": undefined, - }), - })), + Effect.map( + (name): Parameters[0] => ({ + name, + image: normalizeImageRef(news.image), + command: news.command, + env: normalizeEnvironment(news.environment), + volume: news.volumes?.map( + (v) => `${v.hostPath}:${v.containerPath}${v.readOnly ? ":ro" : ""}`, + ), + p: news.ports?.map( + (port) => + `${port.external}:${port.internal}/${port.protocol ?? "tcp"}`, + ), + restart: news.restart ?? "no", + rm: news.removeOnExit ?? false, + ...(news.healthcheck + ? { + "health-cmd": Array.isArray(news.healthcheck.cmd) + ? news.healthcheck.cmd.join(" ") + : news.healthcheck.cmd, + "health-interval": normalizeDuration(news.healthcheck.interval), + "health-timeout": normalizeDuration(news.healthcheck.timeout), + "health-retries": news.healthcheck.retries ?? 0, + "health-start-period": normalizeDuration( + news.healthcheck.startPeriod, + ), + "health-start-interval": normalizeDuration( + news.healthcheck.startInterval, + ), + } + : { + "health-cmd": undefined, + "health-interval": undefined, + "health-timeout": undefined, + "health-retries": undefined, + "health-start-period": undefined, + "health-start-interval": undefined, + }), + }), + ), ); const toContainerAttributes = ( diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index 9474d2679e..e8b232c74f 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -155,12 +155,14 @@ const makeNetworkArgs = ( instanceId: string, ) => networkName(id, props, instanceId).pipe( - Effect.map((name) => ({ - name, - driver: props?.driver ?? "bridge", - ipv6: props?.enableIPv6 ?? false, - label: props?.labels ?? {}, - })), + Effect.map( + (name): Parameters[0] => ({ + name, + driver: props?.driver ?? "bridge", + ipv6: props?.enableIPv6 ?? false, + label: props?.labels ?? {}, + }), + ), ); export const toNetworkAttributes = ( diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index df44785357..f4e747bfda 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -115,8 +115,8 @@ export const VolumeProvider = () => if ( output?.name !== args.name || output?.driver !== args.driver || - !Equal.equals(output?.driverOpts, args.driverOpts) || - !Equal.equals(output?.labels, args.labels) + !Equal.equals(output?.driverOpts ?? {}, args.opt ?? {}) || + !Equal.equals(output?.labels ?? {}, args.label ?? {}) ) { return { action: "replace" as const, deleteFirst: true }; } @@ -155,12 +155,14 @@ const volumeName = (id: string, props: VolumeProps, instanceId: string) => const makeVolumeArgs = (id: string, props: VolumeProps, instanceId: string) => volumeName(id, props, instanceId).pipe( - Effect.map((name) => ({ - name, - driver: props.driver ?? "local", - driverOpts: props.driverOpts, - labels: props.labels, - })), + Effect.map( + (name): Parameters[0] => ({ + name, + driver: props.driver ?? "local", + opt: props.driverOpts, + label: props.labels, + }), + ), ); export const toVolumeAttributes = ( diff --git a/packages/alchemy/test/Bundle/Docker.test.ts b/packages/alchemy/test/Bundle/Docker.test.ts deleted file mode 100644 index 87f5399e48..0000000000 --- a/packages/alchemy/test/Bundle/Docker.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Docker, DockerLive } from "@/Docker"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, layer } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import { spawnSync } from "node:child_process"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; - -layer(Layer.provideMerge(DockerLive, NodeServices.layer))( - "docker context helpers", - (it) => { - it.effect("materializes a Dockerfile in the target directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const docker = yield* Docker; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-ctx-", - }); - const ctx = path.join(root, "ctx"); - yield* docker.materialize({ - context: ctx, - dockerfile: "FROM scratch\n", - files: [], - }); - const dockerfile = path.join(ctx, "Dockerfile"); - expect(yield* fs.exists(dockerfile)).toBe(true); - expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n"); - }), - ); - - it.effect("writes nested context files", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const docker = yield* Docker; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-path-", - }); - const ctx = path.join(root, "ctx"); - yield* docker.materialize({ - context: ctx, - dockerfile: "FROM scratch\n", - files: [{ path: "nested/hello.txt", content: "hi" }], - }); - expect( - yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")), - ).toBe("hi"); - }), - ); - }, -); - -layer(Layer.provideMerge(DockerLive, NodeServices.layer))( - "dockerBuild", - (it) => { - if (dockerDaemonOk) { - it.effect("builds a minimal image with content Dockerfile", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const docker = yield* Docker; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-build-", - }); - const ctx = path.join(root, "ctx"); - const tag = "alchemy-docker-test:minimal"; - yield* docker.materialize({ - context: ctx, - dockerfile: [ - "FROM alpine:3.19", - "RUN echo ok > /tmp/ok.txt", - 'CMD ["cat", "/tmp/ok.txt"]', - "", - ].join("\n"), - files: [], - }); - yield* docker.image.build({ - tag, - context: ctx, - }); - const inspect = yield* docker.image.inspect(tag); - expect(inspect.Id.length).toBeGreaterThan(0); - yield* docker.image - .remove(tag) - .pipe( - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.void, - ), - ); - }), - ); - - it.effect("passes --platform and --build-arg", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const docker = yield* Docker; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-build-", - }); - const ctx = path.join(root, "ctx"); - const tag = "alchemy-docker-test:args"; - yield* docker.materialize({ - context: ctx, - dockerfile: [ - "FROM alpine:3.19", - "ARG FOO=default", - 'RUN echo "$FOO" > /out.txt', - "", - ].join("\n"), - files: [], - }); - yield* docker.image.build({ - tag, - context: ctx, - platform: "linux/amd64", - "build-arg": { FOO: "from-arg" }, - }); - const out = yield* docker.run([ - "run", - "--rm", - tag, - "cat", - "/out.txt", - ]); - expect(out.stdout.trim()).toBe("from-arg"); - yield* docker.image - .remove(tag) - .pipe( - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.void, - ), - ); - }), - ); - - it.effect("respects multi-stage --target", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const docker = yield* Docker; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-build-", - }); - const ctx = path.join(root, "ctx"); - const tag = "alchemy-docker-test:target"; - yield* docker.materialize({ - context: ctx, - dockerfile: [ - "FROM alpine:3.19 AS base", - "RUN echo base > /stage.txt", - "", - "FROM alpine:3.19 AS secondary", - "RUN echo secondary > /stage.txt", - "", - ].join("\n"), - files: [], - }); - yield* docker.image.build({ - tag, - context: ctx, - target: "secondary", - }); - const out = yield* docker.run([ - "run", - "--rm", - tag, - "cat", - "/stage.txt", - ]); - expect(out.stdout.trim()).toBe("secondary"); - yield* docker.image - .remove(tag) - .pipe( - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.void, - ), - ); - }), - ); - } else { - it.skip("builds a minimal image with content Dockerfile", () => {}); - it.skip("passes --platform and --build-arg", () => {}); - it.skip("respects multi-stage --target", () => {}); - } - }, -); diff --git a/packages/alchemy/test/Docker/Container.test.ts b/packages/alchemy/test/Docker/Container.test.ts new file mode 100644 index 0000000000..0fb4b3f34d --- /dev/null +++ b/packages/alchemy/test/Docker/Container.test.ts @@ -0,0 +1,166 @@ +import * as Docker from "@/Docker"; +import * as Provider from "@/Provider"; +import { inMemoryState } from "@/State"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:net"; +import { describe } from "vitest"; + +const dockerDaemonOk = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const freeHostPort = Effect.promise( + () => + new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : undefined; + server.close((error) => { + if (error) { + reject(error); + } else if (port) { + resolve(port); + } else { + reject(new Error("Failed to allocate a free host port")); + } + }); + }); + }), +); + +const { test } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), + adopt: true, +}); + +test.provider("diff replaces a container when its image changes", () => + Effect.gen(function* () { + const containerProvider = yield* Provider.findProvider(Docker.Container); + const containerDiff = yield* containerProvider.diff!({ + id: "web", + instanceId: "instance", + olds: { name: "web", image: "nginx:alpine" }, + news: { name: "web", image: "nginx:1.27-alpine" }, + oldBindings: [], + newBindings: [], + output: { + id: "web", + name: "web", + state: "created", + createdAt: 0, + imageRef: "nginx:alpine", + ports: {}, + }, + }); + expect(containerDiff).toEqual({ action: "replace", deleteFirst: true }); + }), +); + +describe.sequential("Docker.Container", () => { + test.provider.skipIf(!dockerDaemonOk)( + "publishes and inspects bound host ports", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const hostPort = yield* freeHostPort; + // No explicit name: rely on the engine-generated physical name. + const container = yield* stack.deploy( + Docker.Container("nginx-container", { + image: "nginx:alpine", + ports: [{ external: hostPort, internal: 80 }], + start: true, + }), + ); + expect(container.name.length).toBeGreaterThan(0); + expect(container.state).toBe("running"); + + const runtime = yield* docker.container.inspect(container.name); + expect(runtime.NetworkSettings.Ports).toMatchObject({ + "80/tcp": [ + { HostIp: "0.0.0.0", HostPort: `${hostPort}` }, + { HostIp: "::", HostPort: `${hostPort}` }, + ], + }); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "creates a stopped container when start is false", + (stack) => + Effect.gen(function* () { + const container = yield* stack.deploy( + Docker.Container("stopped-container", { + image: "nginx:alpine", + start: false, + }), + ); + expect(container.state).toBe("created"); + expect(container.imageRef).toBe("nginx:alpine"); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "updates network aliases without replacing the container", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + // No explicit names: the engine generates stable physical names that + // stay constant across the two deploys (same instance id). + const deployWithAlias = (alias: string) => + stack.deploy( + Effect.gen(function* () { + const network = yield* Docker.Network("alias-network"); + const container = yield* Docker.Container("alias-container", { + image: "nginx:alpine", + networks: [{ name: network.name, aliases: [alias] }], + }); + return { container, network }; + }), + ); + + const first = yield* deployWithAlias("old-alias"); + const second = yield* deployWithAlias("new-alias"); + expect(second.container.id).toBe(first.container.id); + + const info = yield* docker.container.inspect(second.container.name); + const aliases = + info?.NetworkSettings.Networks?.[second.network.name]?.Aliases ?? []; + expect(aliases).toContain("new-alias"); + expect(aliases).not.toContain("old-alias"); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "replaces the container when published ports change", + (stack) => + Effect.gen(function* () { + const firstPort = yield* freeHostPort; + const secondPort = yield* freeHostPort; + const first = yield* stack.deploy( + Docker.Container("ported-container", { + image: "nginx:alpine", + ports: [{ external: firstPort, internal: 80 }], + }), + ); + const second = yield* stack.deploy( + Docker.Container("ported-container", { + image: "nginx:alpine", + ports: [{ external: secondPort, internal: 80 }], + }), + ); + expect(second.id).not.toBe(first.id); + expect(second.ports["80/tcp"]).toBe(secondPort); + }), + { timeout: 120000 }, + ); +}); diff --git a/packages/alchemy/test/Docker/Docker.integration.test.ts b/packages/alchemy/test/Docker/Docker.integration.test.ts deleted file mode 100644 index 91a2e59eb8..0000000000 --- a/packages/alchemy/test/Docker/Docker.integration.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; -import { hashDirectory } from "@/Command/Memo"; -import * as Docker from "@/Docker"; -import * as Provider from "@/Provider"; -import { inMemoryState } from "@/State"; -import * as Test from "@/Test/Vitest"; -import { expect } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Redacted from "effect/Redacted"; -import { spawnSync } from "node:child_process"; -import { createServer } from "node:net"; -import { describe } from "vitest"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; - -const freeHostPort = Effect.promise( - () => - new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = - typeof address === "object" && address ? address.port : undefined; - server.close((error) => { - if (error) { - reject(error); - } else if (port) { - resolve(port); - } else { - reject(new Error("Failed to allocate a free host port")); - } - }); - }); - }), -); - -const { test } = Test.make({ - providers: Docker.providers(), - state: inMemoryState(), - adopt: true, -}); - -const { test: nonAdoptTest } = Test.make({ - providers: Docker.providers(), - state: inMemoryState(), -}); - -const findOwnedError = ( - cause: Cause.Cause, -): OwnedBySomeoneElse | undefined => - cause.reasons - .map((reason) => - Cause.isFailReason(reason) - ? reason.error - : Cause.isDieReason(reason) - ? reason.defect - : undefined, - ) - .find( - (value): value is OwnedBySomeoneElse => - value instanceof OwnedBySomeoneElse, - ); - -test.provider("provider diff canaries for replacements and registry refs", () => - Effect.gen(function* () { - const volumeProvider = yield* Provider.findProvider(Docker.Volume); - const networkProvider = yield* Provider.findProvider(Docker.Network); - const imageProvider = yield* Provider.findProvider(Docker.Image); - - const volumeDiff = yield* volumeProvider.diff!({ - id: "data", - instanceId: "instance", - olds: { name: "data", labels: { usage: "old" } }, - news: { name: "data", labels: { usage: "new" } }, - oldBindings: [], - newBindings: [], - output: { - id: "data", - name: "data", - driver: "local", - driverOpts: {}, - labels: { usage: "old" }, - mountpoint: undefined, - createdAt: 0, - }, - }); - expect(volumeDiff).toEqual({ action: "replace", deleteFirst: true }); - - const networkDiff = yield* networkProvider.diff!({ - id: "app", - instanceId: "instance", - olds: { name: "app", labels: { usage: "old" } }, - news: { name: "app", labels: { usage: "new" } }, - oldBindings: [], - newBindings: [], - output: { - id: "app", - name: "app", - driver: "bridge", - enableIPv6: false, - labels: { usage: "old" }, - createdAt: 0, - }, - }); - expect(networkDiff).toEqual({ action: "replace", deleteFirst: true }); - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const buildDir = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-canary-", - }); - try { - yield* fs.writeFileString( - path.join(buildDir, "Dockerfile"), - "FROM scratch\n", - ); - const contextHash = yield* hashDirectory({ cwd: buildDir }); - const imageProps = { - name: "acme/app", - tag: "latest", - build: { context: buildDir }, - registry: { - server: "ghcr.io", - username: "octocat", - password: Redacted.make("token"), - }, - }; - // No change + registry-host prefixing must not trigger a spurious update: - // desiredImageRef("acme/app:latest") resolves to "ghcr.io/acme/app:latest". - const imageDiff = yield* imageProvider.diff!({ - id: "app-image", - instanceId: "instance", - olds: imageProps, - news: imageProps, - oldBindings: [], - newBindings: [], - output: { - name: "ghcr.io/acme/app", - imageRef: "ghcr.io/acme/app:latest", - imageId: "sha256:0", - tag: "latest", - builtAt: 0, - contextHash, - }, - }); - expect(imageDiff).toBeUndefined(); - } finally { - yield* fs.remove(buildDir, { recursive: true }).pipe(Effect.ignore); - } - }), -); - -describe.sequential("Docker resources", () => { - nonAdoptTest.provider.skipIf(!dockerDaemonOk)( - "network refuses pre-existing Docker network unless explicitly adopted", - (stack) => - Effect.gen(function* () { - const docker = yield* Docker.Docker; - const networkName = "alchemy-test-network-adoption"; - yield* docker.network - .remove(networkName) - .pipe( - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), - ); - yield* docker.network.create({ name: networkName, driver: "bridge" }); - try { - const error = yield* stack - .deploy( - Effect.gen(function* () { - return yield* Docker.Network("existing-network", { - name: networkName, - }); - }), - ) - .pipe( - Effect.as(undefined), - Effect.catchCause((cause) => - Effect.succeed(findOwnedError(cause)), - ), - ); - expect(error).toBeInstanceOf(OwnedBySomeoneElse); - - const network = yield* stack.deploy( - Effect.gen(function* () { - return yield* Docker.Network("existing-network", { - name: networkName, - }).pipe(adopt(true)); - }), - ); - expect(network.name).toBe(networkName); - expect(network.id.length).toBeGreaterThan(0); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - yield* docker.network.remove(networkName).pipe(Effect.ignore); - } - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "network adopts an existing same-name Docker network with stack adoption", - (stack) => - Effect.gen(function* () { - const docker = yield* Docker.Docker; - const networkName = "alchemy-test-network-adopt-existing"; - yield* docker.network - .remove(networkName) - .pipe( - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), - ); - yield* docker.network.create({ name: networkName, driver: "bridge" }); - const networkInfo = yield* docker.network.inspect(networkName); - console.log(networkInfo); - const network = yield* stack.deploy( - Effect.gen(function* () { - return yield* Docker.Network("existing-network", { - name: networkName, - driver: "bridge", - }); - }), - ); - expect(network.name).toBe(networkName); - expect(network.id.length).toBeGreaterThan(0); - yield* stack.destroy(); - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "volume adopts an existing Docker volume", - (stack) => - Effect.gen(function* () { - const docker = yield* Docker.Docker; - const volumeName = "alchemy-test-volume-adopt-existing"; - yield* docker.volume - .remove(volumeName) - .pipe( - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), - ); - yield* docker.volume.create({ name: volumeName }); - const volume = yield* stack.deploy( - Docker.Volume("existing-volume", { - name: volumeName, - }), - ); - expect(volume.name).toBe(volumeName); - expect(volume.id).toBe(volumeName); - expect(volume.driver).toBe("local"); - yield* stack.destroy(); - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "remote image pulls then re-tags under a new repository", - (stack) => - Effect.gen(function* () { - const docker = yield* Docker.Docker; - const targetName = "alchemy-test-hello"; - const targetTag = "retagged"; - const targetRef = `${targetName}:${targetTag}`; - yield* docker.image - .remove([targetRef], true) - .pipe( - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), - ); - try { - const image = yield* stack.deploy( - Effect.gen(function* () { - return yield* Docker.RemoteImage("retagged-hello", { - name: "hello-world", - tag: "latest", - targetName, - targetTag, - }); - }), - ); - expect(image.imageRef).toBe(targetRef); - expect(image.name).toBe(targetName); - expect(image.tag).toBe(targetTag); - expect(image.imageId?.length).toBeGreaterThan(0); - const inspected = yield* docker.image.inspect(targetRef); - expect(inspected.Id.length).toBeGreaterThan(0); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - yield* docker.image.remove([targetRef], true).pipe(Effect.ignore); - } - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "image builds a tiny Dockerfile", - (stack) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const docker = yield* Docker.Docker; - const root = yield* fs.makeTempDirectory({ - prefix: "alchemy-docker-image-", - }); - let imageRef: string | undefined; - try { - yield* fs.writeFileString( - path.join(root, "Dockerfile"), - "FROM scratch\nLABEL alchemy.test=true\n", - ); - const image = yield* stack.deploy( - Effect.gen(function* () { - // No explicit name: the engine auto-generates the physical name. - return yield* Docker.Image("tiny-image", { - tag: "latest", - build: { context: root }, - }); - }), - ); - imageRef = image.imageRef; - expect(image.imageRef.endsWith(":latest")).toBe(true); - expect(image.imageId?.length).toBeGreaterThan(0); - expect(image.contextHash?.length).toBeGreaterThan(0); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - if (imageRef) { - yield* docker.image.remove(imageRef, true).pipe(Effect.ignore); - } - yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore); - } - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "remote image pulls a Docker image reference", - (stack) => - Effect.gen(function* () { - const image = yield* stack.deploy( - Effect.gen(function* () { - return yield* Docker.RemoteImage("remote-nginx", { - name: "nginx", - tag: "alpine", - alwaysPull: false, - }); - }), - ); - expect(image.imageRef).toBe("nginx:alpine"); - expect(image.imageId?.length).toBeGreaterThan(0); - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "container inspect returns bound host ports", - (stack) => - Effect.gen(function* () { - const docker = yield* Docker.Docker; - const hostPort = yield* freeHostPort; - let containerName: string | undefined; - try { - const container = yield* stack.deploy( - Effect.gen(function* () { - // No explicit name: rely on the engine-generated physical name. - return yield* Docker.Container("nginx-container", { - image: "nginx:alpine", - ports: [{ external: hostPort, internal: 80 }], - start: true, - }); - }), - ); - containerName = container.name; - expect(container.name.length).toBeGreaterThan(0); - expect(container.state).toBe("running"); - const runtime = yield* docker.container.inspect(container.name); - expect(runtime.NetworkSettings.Ports).toMatchObject({ - "80/tcp": [ - { HostIp: "0.0.0.0", HostPort: `${hostPort}` }, - { HostIp: "::", HostPort: `${hostPort}` }, - ], - }); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - if (containerName) { - yield* docker.container - .remove(containerName, true) - .pipe(Effect.ignore); - } - } - }), - { timeout: 120000 }, - ); - - test.provider.skipIf(!dockerDaemonOk)( - "container network aliases update without replacing the container", - (stack) => - Effect.gen(function* () { - const docker = yield* Docker.Docker; - let containerName: string | undefined; - let networkName: string | undefined; - try { - // No explicit names: the engine generates stable physical names that - // stay constant across the two deploys (same instance id). - const deployWithAlias = (alias: string) => - stack.deploy( - Effect.gen(function* () { - const network = yield* Docker.Network("alias-network"); - const container = yield* Docker.Container("alias-container", { - image: "nginx:alpine", - networks: [{ name: network.name, aliases: [alias] }], - }); - return { container, network }; - }), - ); - - const first = yield* deployWithAlias("old-alias"); - containerName = first.container.name; - networkName = first.network.name; - const second = yield* deployWithAlias("new-alias"); - expect(second.container.id).toBe(first.container.id); - - const info = yield* docker.container.inspect(second.container.name); - const aliases = - info?.NetworkSettings.Networks?.[second.network.name]?.Aliases ?? - []; - expect(aliases).toContain("new-alias"); - expect(aliases).not.toContain("old-alias"); - } finally { - yield* stack.destroy().pipe(Effect.ignore); - if (containerName) { - yield* docker.container - .remove(containerName, true) - .pipe(Effect.ignore); - } - if (networkName) { - yield* docker.network.remove(networkName).pipe(Effect.ignore); - } - } - }), - { timeout: 120000 }, - ); -}); diff --git a/packages/alchemy/test/Docker/Docker.test.ts b/packages/alchemy/test/Docker/Docker.test.ts new file mode 100644 index 0000000000..514072b610 --- /dev/null +++ b/packages/alchemy/test/Docker/Docker.test.ts @@ -0,0 +1,162 @@ +import { Docker, DockerLive } from "@/Docker"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, layer } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { spawnSync } from "node:child_process"; + +const isDockerReady = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const describe = layer(Layer.provideMerge(DockerLive, NodeServices.layer)); + +describe("Docker.materialize", (it) => { + it.effect("materializes a Dockerfile in the target directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-ctx-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: "FROM scratch\n", + files: [], + }); + const dockerfile = path.join(ctx, "Dockerfile"); + expect(yield* fs.exists(dockerfile)).toBe(true); + expect(yield* fs.readFileString(dockerfile)).toBe("FROM scratch\n"); + }), + ); + + it.effect("writes nested context files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-path-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: "FROM scratch\n", + files: [{ path: "nested/hello.txt", content: "hi" }], + }); + expect( + yield* fs.readFileString(path.join(ctx, "nested", "hello.txt")), + ).toBe("hi"); + }), + ); +}); + +describe("Docker.image", (it) => { + it.effect.skipIf(!isDockerReady)( + "builds a minimal image with content Dockerfile", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const tag = "alchemy-docker-test:minimal"; + yield* Effect.addFinalizer(() => + docker.image.remove(tag, true).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + Effect.ignore, + ), + ); + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-build-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: [ + "FROM alpine:3.19", + "RUN echo ok > /tmp/ok.txt", + 'CMD ["cat", "/tmp/ok.txt"]', + "", + ].join("\n"), + files: [], + }); + yield* docker.image.build({ tag, context: ctx }); + const inspect = yield* docker.image.inspect(tag); + expect(inspect.Id.length).toBeGreaterThan(0); + }), + ); + + it.effect.skipIf(!isDockerReady)("passes --platform and --build-arg", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const tag = "alchemy-docker-test:args"; + yield* Effect.addFinalizer(() => + docker.image.remove(tag, true).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + Effect.ignore, + ), + ); + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-build-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: [ + "FROM alpine:3.19", + "ARG FOO=default", + 'RUN echo "$FOO" > /out.txt', + "", + ].join("\n"), + files: [], + }); + yield* docker.image.build({ + tag, + context: ctx, + platform: "linux/amd64", + "build-arg": { FOO: "from-arg" }, + }); + const out = yield* docker.run(["run", "--rm", tag, "cat", "/out.txt"]); + expect(out.stdout.trim()).toBe("from-arg"); + }), + ); + + it.effect.skipIf(!isDockerReady)("respects multi-stage --target", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const docker = yield* Docker; + const tag = "alchemy-docker-test:target"; + yield* Effect.addFinalizer(() => + docker.image.remove(tag, true).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + Effect.ignore, + ), + ); + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-build-", + }); + const ctx = path.join(root, "ctx"); + yield* docker.materialize({ + context: ctx, + dockerfile: [ + "FROM alpine:3.19 AS base", + "RUN echo base > /stage.txt", + "", + "FROM alpine:3.19 AS secondary", + "RUN echo secondary > /stage.txt", + "", + ].join("\n"), + files: [], + }); + yield* docker.image.build({ tag, context: ctx, target: "secondary" }); + const out = yield* docker.run(["run", "--rm", tag, "cat", "/stage.txt"]); + expect(out.stdout.trim()).toBe("secondary"); + }), + ); +}); diff --git a/packages/alchemy/test/Docker/DockerApi.test.ts b/packages/alchemy/test/Docker/DockerApi.test.ts deleted file mode 100644 index 9f5f6a3d38..0000000000 --- a/packages/alchemy/test/Docker/DockerApi.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { - buildContainerCreateCommand, - buildImageBuildArgs, - buildNetworkCreateArgs, - buildVolumeCreateArgs, - parseRepoDigest, - repositoryFromImageRef, - toRuntimeInfo, - withRegistryHost, -} from "@/Docker/DockerApi"; -import { desiredImageRef, localImageRef } from "@/Docker/Image"; -import { describe, expect, it } from "@effect/vitest"; -import * as Redacted from "effect/Redacted"; - -describe("Docker CLI helpers", () => { - it("builds volume create argv", () => { - expect( - buildVolumeCreateArgs({ - name: "data", - driver: "local", - driverOpts: { type: "nfs" }, - labels: { usage: "test" }, - }), - ).toEqual([ - "volume", - "create", - "--name", - "data", - "--driver", - "local", - "--opt", - "type=nfs", - "--label", - "usage=test", - ]); - }); - - it("builds network create argv", () => { - expect( - buildNetworkCreateArgs({ - name: "app", - driver: "bridge", - enableIPv6: true, - labels: { app: "alchemy" }, - }), - ).toEqual([ - "network", - "create", - "--driver", - "bridge", - "--ipv6", - "--label", - "app=alchemy", - "app", - ]); - }); - - it("builds image build argv with v1 options", () => { - expect( - buildImageBuildArgs({ - tag: "app:latest", - context: "/tmp/app", - dockerfile: "/tmp/app/Dockerfile", - platform: "linux/amd64", - target: "runner", - args: { NODE_ENV: "production" }, - cacheFrom: ["type=registry,ref=example/app:cache"], - cacheTo: ["type=inline"], - options: ["--pull"], - }), - ).toEqual([ - "build", - "-t", - "app:latest", - "--platform", - "linux/amd64", - "--target", - "runner", - "--cache-from", - "type=registry,ref=example/app:cache", - "--cache-to", - "type=inline", - "--build-arg", - "NODE_ENV=production", - "--pull", - "-f", - "/tmp/app/Dockerfile", - "/tmp/app", - ]); - }); - - it("keeps Redacted container env values out of argv", () => { - const command = buildContainerCreateCommand({ - image: "postgres:16", - name: "db", - environment: { - POSTGRES_PASSWORD: Redacted.make("super-secret"), - }, - ports: [{ external: 5432, internal: 5432 }], - volumes: [ - { hostPath: "db-data", containerPath: "/var/lib/postgresql/data" }, - ], - restart: "unless-stopped", - healthcheck: { - cmd: "pg_isready", - interval: "5s", - }, - }); - - expect(command.args).toContain("--env"); - expect(command.args).toContain("POSTGRES_PASSWORD"); - expect(command.args.join(" ")).not.toContain("super-secret"); - expect(command.env?.POSTGRES_PASSWORD).toBe("super-secret"); - }); - - it("parses runtime port mappings from NetworkSettings and HostConfig fallback", () => { - expect( - toRuntimeInfo({ - Id: "abc", - Created: new Date().toISOString(), - State: { Status: "running" }, - Config: { Image: "nginx", Cmd: null, Env: null }, - HostConfig: { - Binds: null, - RestartPolicy: { Name: "no", MaximumRetryCount: 0 }, - AutoRemove: false, - PortBindings: { - "81/tcp": [{ HostIp: "0.0.0.0", HostPort: "8081" }], - }, - }, - NetworkSettings: { - Networks: {}, - Ports: { - "80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }], - }, - }, - }), - ).toEqual({ ports: { "80/tcp": 8080, "81/tcp": 8081 } }); - }); - - it("normalizes registry push refs and parses repo digest", () => { - expect(withRegistryHost("app:latest", { server: "ghcr.io" })).toBe( - "ghcr.io/app:latest", - ); - expect( - withRegistryHost("localhost:5000/app:latest", { server: "ghcr.io" }), - ).toBe("localhost:5000/app:latest"); - expect( - parseRepoDigest( - "localhost:5000/app:latest", - "latest: digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa size: 123", - ), - ).toBe( - "localhost:5000/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ); - }); - - it("parses image repositories without confusing registry ports for tags", () => { - expect(repositoryFromImageRef("nginx:alpine")).toBe("nginx"); - expect(repositoryFromImageRef("ghcr.io/acme/app:latest")).toBe( - "ghcr.io/acme/app", - ); - expect(repositoryFromImageRef("localhost:5000/acme/app:latest")).toBe( - "localhost:5000/acme/app", - ); - expect( - repositoryFromImageRef( - "localhost:5000/acme/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ), - ).toBe("localhost:5000/acme/app"); - expect( - localImageRef("retagged", { - image: "localhost:5000/acme/app:old", - tag: "new", - }), - ).toBe("localhost:5000/acme/app:new"); - expect( - desiredImageRef("retagged", { - image: "localhost:5000/acme/app:old", - tag: "new", - registry: { - server: "ghcr.io", - username: "octocat", - password: Redacted.make("token"), - }, - }), - ).toBe("localhost:5000/acme/app:new"); - expect( - desiredImageRef("retagged", { - image: "acme/app:old", - tag: "new", - registry: { - server: "ghcr.io", - username: "octocat", - password: Redacted.make("token"), - }, - }), - ).toBe("ghcr.io/acme/app:new"); - }); -}); diff --git a/packages/alchemy/test/Docker/Image.test.ts b/packages/alchemy/test/Docker/Image.test.ts new file mode 100644 index 0000000000..cf7e97563e --- /dev/null +++ b/packages/alchemy/test/Docker/Image.test.ts @@ -0,0 +1,198 @@ +import { hashDirectory } from "@/Command/Memo"; +import * as Docker from "@/Docker"; +import { desiredImageRef, localImageRef } from "@/Docker/Image"; +import * as Provider from "@/Provider"; +import { inMemoryState } from "@/State"; +import * as Test from "@/Test/Vitest"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import { spawnSync } from "node:child_process"; + +const dockerDaemonOk = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const { test } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), + adopt: true, +}); + +const registry = { + server: "ghcr.io", + username: "octocat", + password: Redacted.make("token"), +}; + +describe("ref helpers", () => { + it("localImageRef falls back to the logical id for the repository", () => { + expect(localImageRef("app-image", { build: { context: "." } })).toBe( + "app-image:latest", + ); + }); + + it("localImageRef uses the explicit name and tag", () => { + expect( + localImageRef("app-image", { + build: { context: "." }, + name: "acme/app", + tag: "v1", + }), + ).toBe("acme/app:v1"); + }); + + it("desiredImageRef prefixes the registry host when pushing", () => { + expect( + desiredImageRef("app-image", { + build: { context: "." }, + name: "acme/app", + tag: "v1", + registry, + }), + ).toBe("ghcr.io/acme/app:v1"); + }); + + it("desiredImageRef keeps the local ref when not pushing", () => { + expect( + desiredImageRef("app-image", { + build: { context: "." }, + name: "acme/app", + tag: "v1", + registry, + skipPush: true, + }), + ).toBe("acme/app:v1"); + }); +}); + +test.provider("diff does not flag a spurious update when nothing changed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const imageProvider = yield* Provider.findProvider(Docker.Image); + const buildDir = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-canary-", + }); + yield* fs.writeFileString( + path.join(buildDir, "Dockerfile"), + "FROM scratch\n", + ); + const contextHash = yield* hashDirectory({ cwd: buildDir }); + const imageProps = { + name: "acme/app", + tag: "latest", + build: { context: buildDir }, + registry, + }; + // Registry-host prefixing must not look like a change: + // desiredImageRef("acme/app:latest") resolves to "ghcr.io/acme/app:latest". + const imageDiff = yield* imageProvider.diff!({ + id: "app-image", + instanceId: "instance", + olds: imageProps, + news: imageProps, + oldBindings: [], + newBindings: [], + output: { + name: "ghcr.io/acme/app", + imageRef: "ghcr.io/acme/app:latest", + imageId: "sha256:0", + tag: "latest", + builtAt: 0, + contextHash, + }, + }); + expect(imageDiff).toBeUndefined(); + }), +); + +describe.sequential("Docker.Image", () => { + test.provider.skipIf(!dockerDaemonOk)( + "builds a tiny Dockerfile with an auto-generated name", + (stack) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-image-", + }); + yield* fs.writeFileString( + path.join(root, "Dockerfile"), + "FROM scratch\nLABEL alchemy.test=true\n", + ); + // No explicit name: the engine auto-generates the physical name. + const image = yield* stack.deploy( + Docker.Image("tiny-image", { + tag: "latest", + build: { context: root }, + }), + ); + expect(image.imageRef.endsWith(":latest")).toBe(true); + expect(image.imageId.length).toBeGreaterThan(0); + expect(image.contextHash?.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "builds with an explicit repository name and tag", + (stack) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-image-named-", + }); + yield* fs.writeFileString( + path.join(root, "Dockerfile"), + "FROM scratch\nLABEL alchemy.test=named\n", + ); + const image = yield* stack.deploy( + Docker.Image("named-image", { + name: "alchemy-test-named", + tag: "v1", + build: { context: root }, + }), + ); + expect(image.name).toBe("alchemy-test-named"); + expect(image.imageRef).toBe("alchemy-test-named:v1"); + expect(image.tag).toBe("v1"); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "rebuilds when the build context changes", + (stack) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-image-rebuild-", + }); + const dockerfile = path.join(root, "Dockerfile"); + + yield* fs.writeFileString(dockerfile, "FROM scratch\nLABEL gen=1\n"); + const first = yield* stack.deploy( + Docker.Image("rebuilt-image", { + tag: "latest", + build: { context: root }, + }), + ); + + yield* fs.writeFileString(dockerfile, "FROM scratch\nLABEL gen=2\n"); + const second = yield* stack.deploy( + Docker.Image("rebuilt-image", { + tag: "latest", + build: { context: root }, + }), + ); + + expect(second.imageRef).toBe(first.imageRef); + expect(second.contextHash).not.toBe(first.contextHash); + }), + { timeout: 120000 }, + ); +}); diff --git a/packages/alchemy/test/Docker/Network.test.ts b/packages/alchemy/test/Docker/Network.test.ts new file mode 100644 index 0000000000..88584cdf7a --- /dev/null +++ b/packages/alchemy/test/Docker/Network.test.ts @@ -0,0 +1,177 @@ +import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; +import * as Docker from "@/Docker"; +import * as Provider from "@/Provider"; +import { inMemoryState } from "@/State"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { spawnSync } from "node:child_process"; +import { describe } from "vitest"; + +const dockerDaemonOk = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const { test } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), + adopt: true, +}); + +const { test: nonAdoptTest } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), +}); + +const findOwnedError = ( + cause: Cause.Cause, +): OwnedBySomeoneElse | undefined => + cause.reasons + .map((reason) => + Cause.isFailReason(reason) + ? reason.error + : Cause.isDieReason(reason) + ? reason.defect + : undefined, + ) + .find( + (value): value is OwnedBySomeoneElse => + value instanceof OwnedBySomeoneElse, + ); + +test.provider("diff replaces a network when labels change", () => + Effect.gen(function* () { + const networkProvider = yield* Provider.findProvider(Docker.Network); + const networkDiff = yield* networkProvider.diff!({ + id: "app", + instanceId: "instance", + olds: { name: "app", labels: { usage: "old" } }, + news: { name: "app", labels: { usage: "new" } }, + oldBindings: [], + newBindings: [], + output: { + id: "app", + name: "app", + driver: "bridge", + enableIPv6: false, + labels: { usage: "old" }, + createdAt: 0, + }, + }); + expect(networkDiff).toEqual({ action: "replace", deleteFirst: true }); + }), +); + +describe.sequential("Docker.Network", () => { + test.provider.skipIf(!dockerDaemonOk)( + "creates a bridge network", + (stack) => + Effect.gen(function* () { + const network = yield* stack.deploy( + Docker.Network("created-network", { + name: "alchemy-test-network-create", + labels: { "com.alchemy.test": "true" }, + }), + ); + expect(network.name).toBe("alchemy-test-network-create"); + expect(network.driver).toBe("bridge"); + expect(network.id.length).toBeGreaterThan(0); + expect(network.labels["com.alchemy.test"]).toBe("true"); + }), + { timeout: 120000 }, + ); + + nonAdoptTest.provider.skipIf(!dockerDaemonOk)( + "refuses a pre-existing network unless explicitly adopted", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const networkName = "alchemy-test-network-adoption"; + yield* Effect.addFinalizer(() => + docker.network.remove(networkName).pipe(Effect.ignore), + ); + yield* docker.network + .remove(networkName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); + yield* docker.network.create({ name: networkName, driver: "bridge" }); + + const error = yield* stack + .deploy(Docker.Network("existing-network", { name: networkName })) + .pipe( + Effect.as(undefined), + Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))), + ); + expect(error).toBeInstanceOf(OwnedBySomeoneElse); + + const network = yield* stack.deploy( + Docker.Network("existing-network", { name: networkName }).pipe( + adopt(true), + ), + ); + expect(network.name).toBe(networkName); + expect(network.id.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "adopts an existing same-name network with stack adoption", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const networkName = "alchemy-test-network-adopt-existing"; + yield* Effect.addFinalizer(() => + docker.network.remove(networkName).pipe(Effect.ignore), + ); + yield* docker.network + .remove(networkName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); + yield* docker.network.create({ name: networkName, driver: "bridge" }); + + const network = yield* stack.deploy( + Docker.Network("existing-network", { + name: networkName, + driver: "bridge", + }), + ); + expect(network.name).toBe(networkName); + expect(network.id.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "replaces a network when its labels change", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + // Auto-generated name: a replacement gets a fresh physical name, so + // the new network never collides with the one being torn down. + const name = "alchemy-test-network-replace"; + yield* docker.network + .remove(name) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); + const first = yield* stack.deploy( + Docker.Network("replaceable-network", { + name, + labels: { generation: "1" }, + }), + ); + const second = yield* stack.deploy( + Docker.Network("replaceable-network", { + name, + labels: { generation: "2" }, + }), + ); + expect(second.id).not.toBe(first.id); + expect(second.labels.generation).toBe("2"); + }), + { timeout: 120000 }, + ); +}); diff --git a/packages/alchemy/test/Docker/Registry.test.ts b/packages/alchemy/test/Docker/Registry.test.ts new file mode 100644 index 0000000000..ef4a7a6cf3 --- /dev/null +++ b/packages/alchemy/test/Docker/Registry.test.ts @@ -0,0 +1,109 @@ +import { + parseCreatedAt, + parseRepoDigest, + repositoryFromImageRef, + withRegistryHost, +} from "@/Docker/Registry"; +import { describe, expect, it } from "@effect/vitest"; + +describe("repositoryFromImageRef", () => { + it("strips a simple tag", () => { + expect(repositoryFromImageRef("nginx:alpine")).toBe("nginx"); + }); + + it("keeps the registry host and path", () => { + expect(repositoryFromImageRef("ghcr.io/acme/app:latest")).toBe( + "ghcr.io/acme/app", + ); + }); + + it("does not confuse a registry port for a tag", () => { + expect(repositoryFromImageRef("localhost:5000/acme/app:latest")).toBe( + "localhost:5000/acme/app", + ); + }); + + it("strips a digest", () => { + expect( + repositoryFromImageRef( + "localhost:5000/acme/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ).toBe("localhost:5000/acme/app"); + }); + + it("returns a bare repository unchanged", () => { + expect(repositoryFromImageRef("nginx")).toBe("nginx"); + }); +}); + +describe("withRegistryHost", () => { + it("prefixes a bare reference with the registry host", () => { + expect(withRegistryHost("app:latest", { server: "ghcr.io" })).toBe( + "ghcr.io/app:latest", + ); + }); + + it("trims a trailing slash from the server", () => { + expect(withRegistryHost("app:latest", { server: "ghcr.io/" })).toBe( + "ghcr.io/app:latest", + ); + }); + + it("leaves a reference that already has a dotted-host prefix", () => { + expect( + withRegistryHost("registry.example.com/app:latest", { + server: "ghcr.io", + }), + ).toBe("registry.example.com/app:latest"); + }); + + it("leaves a localhost:port reference untouched", () => { + expect( + withRegistryHost("localhost:5000/app:latest", { server: "ghcr.io" }), + ).toBe("localhost:5000/app:latest"); + }); +}); + +describe("parseRepoDigest", () => { + it("extracts the repo digest from push output", () => { + expect( + parseRepoDigest( + "localhost:5000/app:latest", + "latest: digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa size: 123", + ), + ).toBe( + "localhost:5000/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + }); + + it("returns undefined when no digest is present", () => { + expect(parseRepoDigest("app:latest", "Pushed without a digest")).toBe( + undefined, + ); + }); +}); + +describe("parseCreatedAt", () => { + it("parses an RFC 3339 timestamp", () => { + const created = "2026-06-22T20:53:00.395Z"; + expect(parseCreatedAt(created)).toBe(Date.parse(created)); + }); + + it("falls back to the wall clock when omitted", () => { + const before = Date.now(); + const result = parseCreatedAt(undefined); + expect(result).toBeGreaterThanOrEqual(before); + }); + + it("falls back to the wall clock for an empty string", () => { + const before = Date.now(); + expect(parseCreatedAt("")).toBeGreaterThanOrEqual(before); + }); + + it("falls back to the wall clock for the year-1 zero value", () => { + const before = Date.now(); + expect(parseCreatedAt("0001-01-01T00:00:00Z")).toBeGreaterThanOrEqual( + before, + ); + }); +}); diff --git a/packages/alchemy/test/Docker/RemoteImage.test.ts b/packages/alchemy/test/Docker/RemoteImage.test.ts new file mode 100644 index 0000000000..b43ecd529d --- /dev/null +++ b/packages/alchemy/test/Docker/RemoteImage.test.ts @@ -0,0 +1,184 @@ +import * as Docker from "@/Docker"; +import * as Provider from "@/Provider"; +import { inMemoryState } from "@/State"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import { createServer } from "node:net"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import { spawnSync } from "node:child_process"; +import { describe } from "vitest"; + +const dockerDaemonOk = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const freeHostPort = Effect.promise( + () => + new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : undefined; + server.close((error) => { + if (error) reject(error); + else if (port) resolve(port); + else reject(new Error("Failed to allocate a free host port")); + }); + }); + }), +); + +const { test } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), + adopt: true, +}); + +test.provider("diff pulls again unless alwaysPull is disabled", () => + Effect.gen(function* () { + const provider = yield* Provider.findProvider(Docker.RemoteImage); + const output = { + imageRef: "nginx:alpine", + imageId: "sha256:0", + createdAt: 0, + name: "nginx", + tag: "alpine", + }; + + const pinned = yield* provider.diff!({ + id: "nginx", + instanceId: "instance", + olds: { name: "nginx", tag: "alpine", alwaysPull: false }, + news: { name: "nginx", tag: "alpine", alwaysPull: false }, + oldBindings: [], + newBindings: [], + output, + }); + expect(pinned).toBeUndefined(); + + const refreshed = yield* provider.diff!({ + id: "nginx", + instanceId: "instance", + olds: { name: "nginx", tag: "alpine", alwaysPull: false }, + news: { name: "nginx", tag: "alpine" }, + oldBindings: [], + newBindings: [], + output, + }); + expect(refreshed).toEqual({ action: "update" }); + }), +); + +describe.sequential("Docker.RemoteImage", () => { + test.provider.skipIf(!dockerDaemonOk)( + "pulls a Docker image reference", + (stack) => + Effect.gen(function* () { + const image = yield* stack.deploy( + Docker.RemoteImage("remote-nginx", { + name: "nginx", + tag: "alpine", + alwaysPull: false, + }), + ); + expect(image.imageRef).toBe("nginx:alpine"); + expect(image.imageId.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "pulls then re-tags under a new repository", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const targetName = "alchemy-test-hello"; + const targetTag = "retagged"; + const targetRef = `${targetName}:${targetTag}`; + // RemoteImage.delete is a no-op, so reclaim the re-tagged image here. + yield* Effect.addFinalizer(() => + docker.image.remove([targetRef], true).pipe(Effect.ignore), + ); + + const image = yield* stack.deploy( + Docker.RemoteImage("retagged-hello", { + name: "hello-world", + tag: "latest", + targetName, + targetTag, + }), + ); + expect(image.imageRef).toBe(targetRef); + expect(image.name).toBe(targetName); + expect(image.tag).toBe(targetTag); + expect(image.imageId.length).toBeGreaterThan(0); + + const inspected = yield* docker.image.inspect(targetRef); + expect(inspected.Id.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "pulls, re-tags, and pushes to a registry", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const client = yield* HttpClient.HttpClient; + const port = yield* freeHostPort; + const registryName = "alchemy-test-registry"; + const host = `localhost:${port}`; + const targetName = `${host}/alchemy-hello`; + const targetTag = "v1"; + const targetRef = `${targetName}:${targetTag}`; + + yield* Effect.addFinalizer(() => + docker.run(["rm", "-f", registryName]).pipe(Effect.ignore), + ); + yield* Effect.addFinalizer(() => + docker.image.remove([targetRef], true).pipe(Effect.ignore), + ); + + yield* docker.run([ + "run", + "-d", + "--name", + registryName, + "-p", + `${port}:5000`, + "registry:2", + ]); + // Wait for the registry HTTP API to start serving before pushing. + yield* client.get(`http://${host}/v2/`).pipe( + Effect.retry({ + schedule: Schedule.exponential("250 millis"), + times: 20, + }), + ); + + const image = yield* stack.deploy( + Docker.RemoteImage("pushed-hello", { + name: "hello-world", + tag: "latest", + targetName, + targetTag, + registry: { + server: host, + username: "alchemy", + password: Redacted.make("ignored-by-insecure-registry"), + }, + }), + ); + + expect(image.imageRef).toBe(targetRef); + expect(image.repoDigest).toBeDefined(); + expect(image.repoDigest).toContain(`${targetName}@sha256:`); + }), + { timeout: 180000 }, + ); +}); diff --git a/packages/alchemy/test/Docker/Volume.test.ts b/packages/alchemy/test/Docker/Volume.test.ts new file mode 100644 index 0000000000..e6b0274a25 --- /dev/null +++ b/packages/alchemy/test/Docker/Volume.test.ts @@ -0,0 +1,115 @@ +import * as Docker from "@/Docker"; +import * as Provider from "@/Provider"; +import { inMemoryState } from "@/State"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { spawnSync } from "node:child_process"; +import { describe } from "vitest"; + +const dockerDaemonOk = + spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; + +const { test } = Test.make({ + providers: Docker.providers(), + state: inMemoryState(), + adopt: true, +}); + +test.provider("diff replaces a volume when labels change", () => + Effect.gen(function* () { + const volumeProvider = yield* Provider.findProvider(Docker.Volume); + const volumeDiff = yield* volumeProvider.diff!({ + id: "data", + instanceId: "instance", + olds: { name: "data", labels: { usage: "old" } }, + news: { name: "data", labels: { usage: "new" } }, + oldBindings: [], + newBindings: [], + output: { + id: "data", + name: "data", + driver: "local", + driverOpts: {}, + labels: { usage: "old" }, + mountpoint: undefined, + createdAt: 0, + }, + }); + expect(volumeDiff).toEqual({ action: "replace", deleteFirst: true }); + }), +); + +describe.sequential("Docker.Volume", () => { + test.provider.skipIf(!dockerDaemonOk)( + "creates a volume with labels", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const volumeName = "alchemy-test-volume-create"; + yield* Effect.addFinalizer(() => + docker.volume.remove(volumeName).pipe(Effect.ignore), + ); + const volume = yield* stack.deploy( + Docker.Volume("created-volume", { + name: volumeName, + labels: { "com.alchemy.test": "true" }, + }), + ); + expect(volume.name).toBe(volumeName); + expect(volume.id).toBe(volumeName); + expect(volume.driver).toBe("local"); + expect(volume.labels["com.alchemy.test"]).toBe("true"); + expect(volume.mountpoint?.length).toBeGreaterThan(0); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "adopts an existing Docker volume", + (stack) => + Effect.gen(function* () { + const docker = yield* Docker.Docker; + const volumeName = "alchemy-test-volume-adopt-existing"; + yield* Effect.addFinalizer(() => + docker.volume.remove(volumeName).pipe(Effect.ignore), + ); + yield* docker.volume + .remove(volumeName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); + yield* docker.volume.create({ name: volumeName }); + + const volume = yield* stack.deploy( + Docker.Volume("existing-volume", { name: volumeName }), + ); + expect(volume.name).toBe(volumeName); + expect(volume.id).toBe(volumeName); + expect(volume.driver).toBe("local"); + }), + { timeout: 120000 }, + ); + + test.provider.skipIf(!dockerDaemonOk)( + "replaces a volume when its labels change", + (stack) => + Effect.gen(function* () { + // Auto-generated name: a replacement gets a fresh physical name, so the + // new volume is a real create (a same-name `volume create` is a no-op). + const first = yield* stack.deploy( + Docker.Volume("replaceable-volume", { + labels: { generation: "1" }, + }), + ); + const second = yield* stack.deploy( + Docker.Volume("replaceable-volume", { + labels: { generation: "2" }, + }), + ); + expect(second.id).not.toBe(first.id); + expect(second.labels.generation).toBe("2"); + }), + { timeout: 120000 }, + ); +}); From c2fe7fd85f33f0ba89ebff0001a5869146720e01 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 20:27:46 -0400 Subject: [PATCH 16/25] more tests --- .../alchemy/test/Docker/Container.test.ts | 52 ++++++------ packages/alchemy/test/Docker/Image.test.ts | 2 +- packages/alchemy/test/Docker/Network.test.ts | 79 ++++++++----------- .../alchemy/test/Docker/RemoteImage.test.ts | 7 +- packages/alchemy/test/Docker/Volume.test.ts | 9 ++- 5 files changed, 67 insertions(+), 82 deletions(-) diff --git a/packages/alchemy/test/Docker/Container.test.ts b/packages/alchemy/test/Docker/Container.test.ts index 0fb4b3f34d..3f09e18b6a 100644 --- a/packages/alchemy/test/Docker/Container.test.ts +++ b/packages/alchemy/test/Docker/Container.test.ts @@ -11,29 +11,6 @@ import { describe } from "vitest"; const dockerDaemonOk = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; -const freeHostPort = Effect.promise( - () => - new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = - typeof address === "object" && address ? address.port : undefined; - server.close((error) => { - if (error) { - reject(error); - } else if (port) { - resolve(port); - } else { - reject(new Error("Failed to allocate a free host port")); - } - }); - }); - }), -); - const { test } = Test.make({ providers: Docker.providers(), state: inMemoryState(), @@ -63,7 +40,7 @@ test.provider("diff replaces a container when its image changes", () => }), ); -describe.sequential("Docker.Container", () => { +describe("Docker.Container", { concurrent: false }, () => { test.provider.skipIf(!dockerDaemonOk)( "publishes and inspects bound host ports", (stack) => @@ -89,7 +66,6 @@ describe.sequential("Docker.Container", () => { ], }); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -105,7 +81,6 @@ describe.sequential("Docker.Container", () => { expect(container.state).toBe("created"); expect(container.imageRef).toBe("nginx:alpine"); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -137,7 +112,6 @@ describe.sequential("Docker.Container", () => { expect(aliases).toContain("new-alias"); expect(aliases).not.toContain("old-alias"); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -161,6 +135,28 @@ describe.sequential("Docker.Container", () => { expect(second.id).not.toBe(first.id); expect(second.ports["80/tcp"]).toBe(secondPort); }), - { timeout: 120000 }, ); }); + +const freeHostPort = Effect.promise( + () => + new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : undefined; + server.close((error) => { + if (error) { + reject(error); + } else if (port) { + resolve(port); + } else { + reject(new Error("Failed to allocate a free host port")); + } + }); + }); + }), +); diff --git a/packages/alchemy/test/Docker/Image.test.ts b/packages/alchemy/test/Docker/Image.test.ts index cf7e97563e..e6f2ccbd68 100644 --- a/packages/alchemy/test/Docker/Image.test.ts +++ b/packages/alchemy/test/Docker/Image.test.ts @@ -108,7 +108,7 @@ test.provider("diff does not flag a spurious update when nothing changed", () => }), ); -describe.sequential("Docker.Image", () => { +describe("Docker.Image", { concurrent: false }, () => { test.provider.skipIf(!dockerDaemonOk)( "builds a tiny Dockerfile with an auto-generated name", (stack) => diff --git a/packages/alchemy/test/Docker/Network.test.ts b/packages/alchemy/test/Docker/Network.test.ts index 88584cdf7a..29b65bdc91 100644 --- a/packages/alchemy/test/Docker/Network.test.ts +++ b/packages/alchemy/test/Docker/Network.test.ts @@ -15,30 +15,8 @@ const dockerDaemonOk = const { test } = Test.make({ providers: Docker.providers(), state: inMemoryState(), - adopt: true, }); -const { test: nonAdoptTest } = Test.make({ - providers: Docker.providers(), - state: inMemoryState(), -}); - -const findOwnedError = ( - cause: Cause.Cause, -): OwnedBySomeoneElse | undefined => - cause.reasons - .map((reason) => - Cause.isFailReason(reason) - ? reason.error - : Cause.isDieReason(reason) - ? reason.defect - : undefined, - ) - .find( - (value): value is OwnedBySomeoneElse => - value instanceof OwnedBySomeoneElse, - ); - test.provider("diff replaces a network when labels change", () => Effect.gen(function* () { const networkProvider = yield* Provider.findProvider(Docker.Network); @@ -62,26 +40,24 @@ test.provider("diff replaces a network when labels change", () => }), ); -describe.sequential("Docker.Network", () => { - test.provider.skipIf(!dockerDaemonOk)( - "creates a bridge network", - (stack) => - Effect.gen(function* () { - const network = yield* stack.deploy( - Docker.Network("created-network", { - name: "alchemy-test-network-create", - labels: { "com.alchemy.test": "true" }, - }), - ); - expect(network.name).toBe("alchemy-test-network-create"); - expect(network.driver).toBe("bridge"); - expect(network.id.length).toBeGreaterThan(0); - expect(network.labels["com.alchemy.test"]).toBe("true"); - }), - { timeout: 120000 }, +describe("Docker.Network", { concurrent: false }, () => { + test.provider.skipIf(!dockerDaemonOk)("creates a bridge network", (stack) => + Effect.gen(function* () { + const network = yield* stack.deploy( + Docker.Network("created-network", { + labels: { "com.alchemy.test": "true" }, + }), + ); + expect(network).toMatchObject({ + name: expect.any(String), + driver: "bridge", + id: expect.any(String), + labels: { "com.alchemy.test": "true" }, + }); + }), ); - nonAdoptTest.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!dockerDaemonOk)( "refuses a pre-existing network unless explicitly adopted", (stack) => Effect.gen(function* () { @@ -113,7 +89,6 @@ describe.sequential("Docker.Network", () => { expect(network.name).toBe(networkName); expect(network.id.length).toBeGreaterThan(0); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -136,12 +111,11 @@ describe.sequential("Docker.Network", () => { Docker.Network("existing-network", { name: networkName, driver: "bridge", - }), + }).pipe(adopt(true)), ); expect(network.name).toBe(networkName); expect(network.id.length).toBeGreaterThan(0); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -149,8 +123,6 @@ describe.sequential("Docker.Network", () => { (stack) => Effect.gen(function* () { const docker = yield* Docker.Docker; - // Auto-generated name: a replacement gets a fresh physical name, so - // the new network never collides with the one being torn down. const name = "alchemy-test-network-replace"; yield* docker.network .remove(name) @@ -172,6 +144,21 @@ describe.sequential("Docker.Network", () => { expect(second.id).not.toBe(first.id); expect(second.labels.generation).toBe("2"); }), - { timeout: 120000 }, ); }); + +const findOwnedError = ( + cause: Cause.Cause, +): OwnedBySomeoneElse | undefined => + cause.reasons + .map((reason) => + Cause.isFailReason(reason) + ? reason.error + : Cause.isDieReason(reason) + ? reason.defect + : undefined, + ) + .find( + (value): value is OwnedBySomeoneElse => + value instanceof OwnedBySomeoneElse, + ); diff --git a/packages/alchemy/test/Docker/RemoteImage.test.ts b/packages/alchemy/test/Docker/RemoteImage.test.ts index b43ecd529d..afcf830693 100644 --- a/packages/alchemy/test/Docker/RemoteImage.test.ts +++ b/packages/alchemy/test/Docker/RemoteImage.test.ts @@ -6,9 +6,9 @@ import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; -import { createServer } from "node:net"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { spawnSync } from "node:child_process"; +import { createServer } from "node:net"; import { describe } from "vitest"; const dockerDaemonOk = @@ -74,7 +74,7 @@ test.provider("diff pulls again unless alwaysPull is disabled", () => }), ); -describe.sequential("Docker.RemoteImage", () => { +describe("Docker.RemoteImage", { concurrent: false }, () => { test.provider.skipIf(!dockerDaemonOk)( "pulls a Docker image reference", (stack) => @@ -89,7 +89,6 @@ describe.sequential("Docker.RemoteImage", () => { expect(image.imageRef).toBe("nginx:alpine"); expect(image.imageId.length).toBeGreaterThan(0); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -121,7 +120,6 @@ describe.sequential("Docker.RemoteImage", () => { const inspected = yield* docker.image.inspect(targetRef); expect(inspected.Id.length).toBeGreaterThan(0); }), - { timeout: 120000 }, ); test.provider.skipIf(!dockerDaemonOk)( @@ -179,6 +177,5 @@ describe.sequential("Docker.RemoteImage", () => { expect(image.repoDigest).toBeDefined(); expect(image.repoDigest).toContain(`${targetName}@sha256:`); }), - { timeout: 180000 }, ); }); diff --git a/packages/alchemy/test/Docker/Volume.test.ts b/packages/alchemy/test/Docker/Volume.test.ts index e6b0274a25..b8ea42b77b 100644 --- a/packages/alchemy/test/Docker/Volume.test.ts +++ b/packages/alchemy/test/Docker/Volume.test.ts @@ -95,8 +95,13 @@ describe.sequential("Docker.Volume", () => { "replaces a volume when its labels change", (stack) => Effect.gen(function* () { - // Auto-generated name: a replacement gets a fresh physical name, so the - // new volume is a real create (a same-name `volume create` is a no-op). + const docker = yield* Docker.Docker; + const volumeName = "alchemy-test-volume-replace"; + yield* docker.volume + .remove(volumeName) + .pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + ); const first = yield* stack.deploy( Docker.Volume("replaceable-volume", { labels: { generation: "1" }, From 3092a58a4c8be89dcb2c96523967fb34d79b0687 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 20:28:40 -0400 Subject: [PATCH 17/25] replace fix (separate pr) --- packages/alchemy/src/Apply.ts | 110 ++++++++++++++++---- packages/alchemy/test/apply.test.ts | 133 ++++++++++++++++++++++++ packages/alchemy/test/test.resources.ts | 107 +++++++++++++++++++ 3 files changed, 331 insertions(+), 19 deletions(-) diff --git a/packages/alchemy/src/Apply.ts b/packages/alchemy/src/Apply.ts index 70b9399293..6533432613 100644 --- a/packages/alchemy/src/Apply.ts +++ b/packages/alchemy/src/Apply.ts @@ -38,6 +38,7 @@ import { type PersistedState, type RanActionState, type ReplacedResourceState, + type ReplacementOldResourceState, type ReplacingResourceState, type ResourceState, type RunningActionState, @@ -854,6 +855,58 @@ const executeNode = ( replState = node.state; } + // ── delete-first replacements ── + // + // By default a replacement is create-first: the new generation is + // created here and the old generation(s) are reclaimed afterwards by + // `collectGarbage` (Phase 2). That ordering keeps the old resource + // alive if the create fails, but it is wrong for resources whose + // replacement cannot coexist with the original — a fixed physical + // name, a singleton, etc. Those providers return + // `{ action: "replace", deleteFirst: true }`. + // + // When `deleteFirst` is set we tear the previous generation(s) down + // BEFORE creating the new one, and commit the result as a terminal + // `created` state (rather than `replaced`) so Phase 2 has no old chain + // left to drain. `delete` is required to be idempotent, so a re-run + // after an interrupted apply simply re-converges. + const deleteOldGenerations = ( + old: ReplacementOldResourceState, + ): Effect.Effect => + Effect.gen(function* () { + const retain = node.resource.RemovalPolicy === "retain"; + if (old.attr !== undefined && !retain) { + yield* node.provider + .delete({ + id: logicalId, + instanceId: old.instanceId, + olds: old.props as never, + output: old.attr, + session: scopedSession, + bindings: [], + }) + .pipe( + instrumentLifecycle( + "delete", + fqn, + node.resource.Type, + logicalId, + old.instanceId, + ), + ); + } + if (old.status === "replacing" || old.status === "replaced") { + yield* deleteOldGenerations(old.old); + } + }); + + if (node.deleteFirst) { + yield* scopedSession.note( + "Deleting previous resource before creating its replacement (deleteFirst)...", + ); + yield* deleteOldGenerations(replState.old); + } + let attr: any = replState.attr; if (attr !== undefined) { @@ -949,25 +1002,44 @@ const executeNode = ( ), ); - yield* commit({ - // Creation of the new generation succeeded; from here on the only remaining - // work is draining the old chain via garbage collection. - status: "replaced", - fqn, - logicalId, - instanceId, - resourceType: node.resource.Type, - props: news, - attr, - providerVersion: node.provider.version ?? 0, - bindings: excludeDeletedBindings(node.bindings), - downstream: node.downstream, - // Preserve the remaining backlog exactly as-is. GC is responsible for - // popping one generation at a time until the chain is exhausted. - old: replState.old, - deleteFirst: node.deleteFirst, - removalPolicy: node.resource.RemovalPolicy, - }); + if (node.deleteFirst) { + // The old generation(s) were already torn down above, so there is + // nothing left for `collectGarbage` to drain — collapse straight to + // the terminal `created` state. + yield* commit({ + status: "created", + fqn, + logicalId, + instanceId, + resourceType: node.resource.Type, + props: news, + attr, + providerVersion: node.provider.version ?? 0, + bindings: excludeDeletedBindings(node.bindings), + downstream: node.downstream, + removalPolicy: node.resource.RemovalPolicy, + }); + } else { + yield* commit({ + // Creation of the new generation succeeded; from here on the only remaining + // work is draining the old chain via garbage collection. + status: "replaced", + fqn, + logicalId, + instanceId, + resourceType: node.resource.Type, + props: news, + attr, + providerVersion: node.provider.version ?? 0, + bindings: excludeDeletedBindings(node.bindings), + downstream: node.downstream, + // Preserve the remaining backlog exactly as-is. GC is responsible for + // popping one generation at a time until the chain is exhausted. + old: replState.old, + deleteFirst: node.deleteFirst, + removalPolicy: node.resource.RemovalPolicy, + }); + } tracker[fqn] = { output: attr, diff --git a/packages/alchemy/test/apply.test.ts b/packages/alchemy/test/apply.test.ts index 5405bbd798..25d614e4ac 100644 --- a/packages/alchemy/test/apply.test.ts +++ b/packages/alchemy/test/apply.test.ts @@ -18,6 +18,8 @@ import * as Redacted from "effect/Redacted"; import { ArtifactProbe, BindingTarget, + CollisionRegistry, + DeleteFirstResource, DeletedBindingRegressionTarget, DurationResource, Function, @@ -522,6 +524,137 @@ describe("linear update propagation", () => { ); }); +// Regression: `deleteFirst` on a `replace` diff was plumbed from the provider +// all the way into persisted state but never *read* — every replacement was +// create-first, with the old generation reclaimed afterwards by Phase-2 GC. +// That silently broke any resource whose replacement can't coexist with the +// original (fixed physical name, singleton): the create collided with the +// not-yet-deleted original. These tests pin both orderings. +describe("deleteFirst replacements", () => { + test.provider( + "deletes the old generation BEFORE creating the replacement", + (stack) => + Effect.gen(function* () { + yield* stack.deploy( + Effect.gen(function* () { + return yield* DeleteFirstResource("R", { replaceString: "v1" }); + }), + ); + + const order: string[] = []; + const recordHooks = { + create: () => + Effect.sync(() => { + order.push("create"); + }), + update: () => Effect.succeed(undefined), + delete: () => + Effect.sync(() => { + order.push("delete"); + }), + }; + + yield* Effect.gen(function* () { + return yield* DeleteFirstResource("R", { replaceString: "v2" }); + }).pipe(stack.deploy, hook(recordHooks)); + + // The whole point: delete-old precedes create-new. + expect(order).toEqual(["delete", "create"]); + + // The resource collapses straight to a terminal `created` state with + // no leftover replacement chain for GC to drain. + const state = yield* getState("R"); + expect(state?.status).toEqual("created"); + expect((state as { old?: unknown }).old).toBeUndefined(); + expect(yield* listState()).toHaveLength(1); + }), + ); + + test.provider( + "default (non-deleteFirst) replacement still creates BEFORE deleting", + (stack) => + Effect.gen(function* () { + // `TestResource` returns a plain `{ action: "replace" }` (deleteFirst + // defaults to false), so the engine must stay create-first. + yield* stack.deploy( + Effect.gen(function* () { + return yield* TestResource("R", { replaceString: "v1" }); + }), + ); + + const order: string[] = []; + yield* Effect.gen(function* () { + return yield* TestResource("R", { replaceString: "v2" }); + }).pipe( + stack.deploy, + hook({ + create: () => + Effect.sync(() => { + order.push("create"); + }), + update: () => Effect.succeed(undefined), + delete: () => + Effect.sync(() => { + order.push("delete"); + }), + }), + ); + + expect(order).toEqual(["create", "delete"]); + }), + ); + + test.provider( + "lets a same-identity replacement succeed where create-first would collide", + (stack) => + Effect.gen(function* () { + // A shared registry of live physical names. The provider's create + // fails if the (fixed) name is still live — exactly the failure mode + // of a real fixed-name resource (Docker network "already exists", + // no-op `volume create`) when create runs before the old is deleted. + const registry = { live: new Set() }; + const withRegistry = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(Layer.succeed(CollisionRegistry, registry)), + ); + + yield* stack + .deploy( + Effect.gen(function* () { + return yield* DeleteFirstResource("R", { + name: "singleton", + replaceString: "v1", + }); + }), + ) + .pipe(withRegistry); + expect(registry.live.has("singleton")).toBe(true); + + // Before the fix this deploy died with a CollisionError because the + // create of the new "singleton" ran while the old one was still live. + const result = yield* stack + .deploy( + Effect.gen(function* () { + return yield* DeleteFirstResource("R", { + name: "singleton", + replaceString: "v2", + }); + }), + ) + .pipe(withRegistry); + + expect(result.name).toEqual("singleton"); + expect(result.replaceString).toEqual("v2"); + // Exactly one live instance remains (old torn down, new created). + expect(registry.live.size).toBe(1); + expect(registry.live.has("singleton")).toBe(true); + + const state = yield* getState("R"); + expect(state?.status).toEqual("created"); + }), + ); +}); + describe("circularity via bindings", () => { const selfBoundStack = (props: { string: string; diff --git a/packages/alchemy/test/test.resources.ts b/packages/alchemy/test/test.resources.ts index 3e956aee98..9746f42b4e 100644 --- a/packages/alchemy/test/test.resources.ts +++ b/packages/alchemy/test/test.resources.ts @@ -845,6 +845,112 @@ export const durationResourceProvider = () => delete: Effect.fn(function* () {}), }); +// DeleteFirstResource — exercises `{ action: "replace", deleteFirst: true }`. +// +// Models a resource whose replacement cannot coexist with the original (a +// fixed physical name / singleton). When `replaceString` changes it asks the +// engine to tear the old generation down BEFORE creating the new one. +// +// Two test affordances: +// - create/update/delete route through `TestResourceHooks` so a test can +// record the order the engine invokes them in. +// - if a `CollisionRegistry` is in context, create fails when an instance +// with the same physical `name` is still live. Under create-first ordering +// a same-name replacement would collide here (reproducing the real Docker +// "network already exists" / no-op `volume create` bug); under delete-first +// it succeeds. + +export class CollisionRegistry extends Context.Service< + CollisionRegistry, + { readonly live: Set } +>()("CollisionRegistry") {} + +export class CollisionError extends Data.TaggedError("CollisionError")<{ + name: string; +}> {} + +export type DeleteFirstResourceProps = { + string?: string; + replaceString?: string; + name?: string; +}; + +export interface DeleteFirstResource extends Resource< + "Test.DeleteFirstResource", + DeleteFirstResourceProps, + { + name: string; + string: string; + replaceString: DeleteFirstResourceProps["replaceString"]; + } +> {} + +export const DeleteFirstResource = Resource( + "Test.DeleteFirstResource", +); + +export const deleteFirstResourceProvider = () => + Provider.succeed(DeleteFirstResource, { + list: () => Effect.succeed([]), + diff: Effect.fn(function* ({ news = {}, olds = {} }) { + if (!isResolved(news)) return undefined; + const n = news as DeleteFirstResourceProps; + const o = olds as DeleteFirstResourceProps; + if (n.replaceString !== o.replaceString) { + return { action: "replace", deleteFirst: true } as const; + } + if (n.string !== o.string) { + return { action: "update" } as const; + } + return undefined; + }), + reconcile: Effect.fn(function* ({ id, news = {}, olds }) { + const name = news.name ?? id; + const hooks = Option.getOrUndefined( + yield* Effect.serviceOption(TestResourceHooks), + ); + const registry = Option.getOrUndefined( + yield* Effect.serviceOption(CollisionRegistry), + ); + // `olds === undefined` ⇒ create (greenfield OR replacement-create); the + // engine clears `olds` when minting the new replacement generation. + if (olds === undefined) { + if (registry?.live.has(name)) { + return yield* Effect.fail(new CollisionError({ name })); + } + registry?.live.add(name); + if (hooks?.create) { + yield* hooks.create(id, { + string: news.string, + replaceString: news.replaceString, + }); + } + } else if (hooks?.update) { + yield* hooks.update(id, { + string: news.string, + replaceString: news.replaceString, + }); + } + return { + name, + string: news.string ?? id, + replaceString: news.replaceString, + }; + }), + delete: Effect.fn(function* ({ id, output }) { + const hooks = Option.getOrUndefined( + yield* Effect.serviceOption(TestResourceHooks), + ); + const registry = Option.getOrUndefined( + yield* Effect.serviceOption(CollisionRegistry), + ); + registry?.live.delete(output.name); + if (hooks?.delete) { + yield* hooks.delete(id); + } + }), + }); + // Layers export const TestLayers = () => Layer.mergeAll( @@ -861,6 +967,7 @@ export const TestLayers = () => phasedTargetProvider(), noPrecreateBindingTargetProvider(), durationResourceProvider(), + deleteFirstResourceProvider(), ); export const InMemoryTestLayers = () => From f34cfaeeee8c9c65bf83c0e2e098e200b8eb3d85 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 20:33:10 -0400 Subject: [PATCH 18/25] log when pushing image --- packages/alchemy/src/Docker/Image.ts | 3 +++ packages/alchemy/src/Docker/RemoteImage.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 1d4a829810..b360071ff3 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -229,6 +229,9 @@ export const ImageProvider = () => let repoDigest: string | undefined; if (props.registry && !props.skipPush) { + yield* session.note( + `Pushing image to registry "${props.registry.server}"`, + ); repoDigest = yield* docker.image .push(ref, props.registry) .pipe( diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index 50b3916e4a..d27c71f6ec 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -163,7 +163,9 @@ export const RemoteImageProvider = () => let repoDigest: string | undefined; if (news.registry && !news.skipPush) { - yield* session.note(`Pushing Docker image: ${finalRef}`); + yield* session.note( + `Pushing image to registry "${news.registry.server}"`, + ); repoDigest = yield* docker.image .push(finalRef, news.registry) .pipe( From 745bca50d584d2ff3809d367318b08072a1e0fda Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 20:47:04 -0400 Subject: [PATCH 19/25] add back inspectContainer --- packages/alchemy/src/Docker/Container.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index ca0787ecb3..dc32c4b519 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -2,6 +2,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import { identity } from "effect/Function"; +import type { PlatformError } from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; @@ -161,6 +162,24 @@ export interface Container extends Resource< */ export const Container = Resource("Docker.Container"); +/** + * Inspect a Docker container by name and return normalized runtime details. + * + * This is a small public wrapper around Docker's raw inspect output. It returns + * the stable data Alchemy callers typically need, including bound host ports. + */ +export const inspectContainer = ( + name: string, +): Effect.Effect< + Exclude, + PlatformError, + Docker +> => + Docker.pipe( + Effect.flatMap((docker) => docker.container.inspect(name)), + Effect.map((container) => toContainerAttributes(container, undefined!)), + ); + export const ContainerProvider = () => Provider.effect( Container, From d9f7000b57d92c9425057e20bc3ab06c08448983 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 21:39:58 -0400 Subject: [PATCH 20/25] fix type errors --- examples/docker-postgres/alchemy.run.ts | 21 +++++++------------ examples/docker-postgres/package.json | 5 ++--- packages/alchemy/src/Docker/Container.ts | 15 +++++++------ packages/alchemy/src/Docker/Image.ts | 5 ++++- packages/alchemy/src/Docker/Network.ts | 5 ++++- packages/alchemy/src/Docker/RemoteImage.ts | 5 ++++- packages/alchemy/src/Docker/Volume.ts | 5 ++++- packages/alchemy/src/Stack.ts | 7 ++++++- .../alchemy/test/Docker/Container.test.ts | 6 +++--- tsconfig.json | 3 +++ 10 files changed, 47 insertions(+), 30 deletions(-) diff --git a/examples/docker-postgres/alchemy.run.ts b/examples/docker-postgres/alchemy.run.ts index 9e8f84c509..82f616b9a7 100644 --- a/examples/docker-postgres/alchemy.run.ts +++ b/examples/docker-postgres/alchemy.run.ts @@ -2,15 +2,18 @@ import * as Alchemy from "alchemy"; import * as Docker from "alchemy/Docker"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; const POSTGRES_PORT = 15432; const POSTGRES_CONTAINER = "alchemy-example-postgres"; -const EMPTY_RUNTIME: Docker.ContainerRuntimeInfo = { ports: {} }; export default Alchemy.Stack( "DockerPostgresExample", - { providers: Docker.providers(), state: Alchemy.localState() }, + { + providers: Layer.merge(Docker.providers(), Alchemy.RandomProvider()), + state: Alchemy.localState(), + }, Effect.gen(function* () { const configuredPassword = yield* Config.redacted("POSTGRES_PASSWORD").pipe( Config.option, @@ -46,26 +49,18 @@ export default Alchemy.Stack( networks: [{ name: network.name, aliases: ["postgres"] }], healthcheck: { cmd: ["CMD-SHELL", "pg_isready -U alchemy -d app"], - interval: "5s", - timeout: "5s", + interval: "5 seconds", + timeout: "5 seconds", retries: 10, }, start: true, }); - - const runtime = yield* Docker.inspectContainer(POSTGRES_CONTAINER).pipe( - Effect.catchTag("DockerCommandError", () => - Effect.succeed(EMPTY_RUNTIME), - ), - ); - return { container: postgres.name, image: image.imageRef, network: network.name, volume: data.name, - hostPort: runtime.ports["5432/tcp"] ?? POSTGRES_PORT, - connectionString: `postgres://alchemy:***@localhost:${POSTGRES_PORT}/app`, + hostPort: postgres.ports, }; }), ); diff --git a/examples/docker-postgres/package.json b/examples/docker-postgres/package.json index 2f3c98a3d4..1686f70367 100644 --- a/examples/docker-postgres/package.json +++ b/examples/docker-postgres/package.json @@ -11,11 +11,10 @@ "type": "module", "scripts": { "deploy": "alchemy deploy", - "destroy": "alchemy destroy", - "build": "tsgo --noEmit -p tsconfig.json" + "destroy": "alchemy destroy" }, "dependencies": { "alchemy": "workspace:*", "effect": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index dc32c4b519..5da03a10f0 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -10,6 +10,7 @@ import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { Docker } from "./Docker.ts"; +import type { Providers } from "./Providers.ts"; export interface ContainerProps { /** Image reference or Docker image resource. */ @@ -37,11 +38,10 @@ export interface ContainerProps { /** Start the container after creation/reconciliation. @default false */ start?: boolean; /** Docker healthcheck configuration. */ - healthcheck?: Container.HealthcheckConfig; + healthcheck?: Container.Healthcheck; } export declare namespace Container { - type Image = string | { imageRef: string }; type Status = | "created" | "running" @@ -50,6 +50,7 @@ export declare namespace Container { | "removing" | "exited" | "dead"; + type Image = string | { imageRef: string }; interface PortMapping { /** External port on the host. */ external: number | string; @@ -72,7 +73,7 @@ export declare namespace Container { /** Network aliases for the container. */ aliases?: string[]; } - interface HealthcheckConfig { + interface Healthcheck { /** Command to run for health checks. */ cmd: string[] | string; /** Time between checks. */ @@ -97,7 +98,7 @@ export interface Container extends Resource< /** Docker container name. */ name: string; /** Docker container state. */ - state: Container.Status; + status: Container.Status; /** Creation timestamp in milliseconds since epoch. */ createdAt: number; /** Image reference used to create the container. */ @@ -107,7 +108,9 @@ export interface Container extends Resource< * Format: `"80/tcp" -> 8080`. */ ports: Record; - } + }, + never, + Providers > {} /** @@ -373,7 +376,7 @@ const toContainerAttributes = ( ): Container["Attributes"] => ({ id: info.Id, name: infoName(info), - state: info.State.Status, + status: info.State.Status, createdAt: Date.parse(info.Created) || Date.now(), imageRef, ports: Object.fromEntries( diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index b360071ff3..f67e1acc10 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -14,6 +14,7 @@ import { repositoryFromImageRef, withRegistryHost, } from "./Registry.ts"; +import type { Providers } from "./Providers.ts"; export interface DockerBuildOptions { /** @@ -75,7 +76,9 @@ export interface Image extends Resource< builtAt: number; /** Hash of the build-context files. */ contextHash?: string; - } + }, + never, + Providers > {} /** diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index e8b232c74f..fb8b35656a 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -7,6 +7,7 @@ import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { Docker } from "./Docker.ts"; +import type { Providers } from "./Providers.ts"; export interface NetworkProps { /** @@ -39,7 +40,9 @@ export interface Network extends Resource< labels: Record; /** Creation timestamp in milliseconds since epoch. */ createdAt: number; - } + }, + never, + Providers > {} /** diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index d27c71f6ec..0572e0b01b 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -10,6 +10,7 @@ import { repositoryFromImageRef, withRegistryHost, } from "./Registry.ts"; +import type { Providers } from "./Providers.ts"; export interface RemoteImageProps { /** Docker image name to pull, without tag. */ @@ -61,7 +62,9 @@ export interface RemoteImage extends Resource< tag: string; /** Registry digest after push when available. */ repoDigest?: string; - } + }, + never, + Providers > {} /** diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index f4e747bfda..3ac8079ba6 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -7,6 +7,7 @@ import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { Docker } from "./Docker.ts"; +import type { Providers } from "./Providers.ts"; export interface VolumeLabel { /** Label name. */ @@ -48,7 +49,9 @@ export interface Volume extends Resource< mountpoint?: string; /** Creation timestamp in milliseconds since epoch. */ createdAt: number; - } + }, + never, + Providers > {} /** diff --git a/packages/alchemy/src/Stack.ts b/packages/alchemy/src/Stack.ts index 99d0cf6a08..08ab9f3a23 100644 --- a/packages/alchemy/src/Stack.ts +++ b/packages/alchemy/src/Stack.ts @@ -50,7 +50,8 @@ export type ProviderServices = | Provider | PolicyLike | EnvironmentLike - | CredentialsLike; + | CredentialsLike + | DockerLike; // tagged type to allow types like AWSEnvironment/AWS Region to bubble through export interface EnvironmentLike { @@ -62,6 +63,10 @@ export interface CredentialsLike { readonly kind: "Credentials"; } +export interface DockerLike { + readonly key: "@alchemy/Docker"; +} + export type StackEffect = Effect.Effect< A, Err, diff --git a/packages/alchemy/test/Docker/Container.test.ts b/packages/alchemy/test/Docker/Container.test.ts index 3f09e18b6a..1e9f048a7d 100644 --- a/packages/alchemy/test/Docker/Container.test.ts +++ b/packages/alchemy/test/Docker/Container.test.ts @@ -30,7 +30,7 @@ test.provider("diff replaces a container when its image changes", () => output: { id: "web", name: "web", - state: "created", + status: "created", createdAt: 0, imageRef: "nginx:alpine", ports: {}, @@ -56,7 +56,7 @@ describe("Docker.Container", { concurrent: false }, () => { }), ); expect(container.name.length).toBeGreaterThan(0); - expect(container.state).toBe("running"); + expect(container.status).toBe("running"); const runtime = yield* docker.container.inspect(container.name); expect(runtime.NetworkSettings.Ports).toMatchObject({ @@ -78,7 +78,7 @@ describe("Docker.Container", { concurrent: false }, () => { start: false, }), ); - expect(container.state).toBe("created"); + expect(container.status).toBe("created"); expect(container.imageRef).toBe("nginx:alpine"); }), ); diff --git a/tsconfig.json b/tsconfig.json index d06748048f..504dad23c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -111,6 +111,9 @@ { "path": "./examples/cloudflare-worker-async/tsconfig.json" }, + { + "path": "./examples/docker-postgres/tsconfig.json" + }, { "path": "./examples/monorepo-single-stack/tsconfig.json" }, From 72699e2e73d1f350d5f2685c2687de68d9d1a6d4 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 22:17:06 -0400 Subject: [PATCH 21/25] simplify image build --- packages/alchemy/src/Docker/Container.ts | 24 +-- packages/alchemy/src/Docker/Docker.ts | 15 ++ packages/alchemy/src/Docker/Image.ts | 161 ++++++++------------- packages/alchemy/src/Docker/Network.ts | 21 +-- packages/alchemy/src/Docker/RemoteImage.ts | 4 +- packages/alchemy/src/Docker/Volume.ts | 17 +-- packages/alchemy/test/Docker/Image.test.ts | 130 +++++------------ 7 files changed, 118 insertions(+), 254 deletions(-) diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index 5da03a10f0..a2f2af248b 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -6,10 +6,9 @@ import type { PlatformError } from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; -import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./Docker.ts"; +import { Docker, dockerPhysicalName } from "./Docker.ts"; import type { Providers } from "./Providers.ts"; export interface ContainerProps { @@ -235,7 +234,7 @@ export const ContainerProvider = () => return Container.Provider.of({ list: () => Effect.succeed([]), read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const name = yield* containerName(id, olds, instanceId); + const name = yield* dockerPhysicalName(id, olds, instanceId); return yield* docker.container.inspect(name).pipe( Effect.map((info) => toContainerAttributes(info, normalizeImageRef(olds.image)), @@ -313,21 +312,11 @@ export const ContainerProvider = () => }), ); -const containerName = (id: string, props: ContainerProps, instanceId: string) => - props.name - ? Effect.succeed(props.name) - : createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }); - const normalizeImageRef = (image: Container.Image): string => typeof image === "string" ? image : image.imageRef; const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) => - containerName(id, news, instanceId).pipe( + dockerPhysicalName(id, news, instanceId).pipe( Effect.map( (name): Parameters[0] => ({ name, @@ -375,7 +364,7 @@ const toContainerAttributes = ( imageRef: string, ): Container["Attributes"] => ({ id: info.Id, - name: infoName(info), + name: typeof info.Name === "string" ? info.Name.replace(/^\//, "") : info.Id, status: info.State.Status, createdAt: Date.parse(info.Created) || Date.now(), imageRef, @@ -390,11 +379,6 @@ const toContainerAttributes = ( ), }); -const infoName = (info: Docker.Container) => { - const name = info.Name; - return typeof name === "string" ? name.replace(/^\//, "") : info.Id; -}; - const normalizeEnvironment = ( environment: Record> | undefined, ): Record => diff --git a/packages/alchemy/src/Docker/Docker.ts b/packages/alchemy/src/Docker/Docker.ts index 5be1555356..008c6ede8d 100644 --- a/packages/alchemy/src/Docker/Docker.ts +++ b/packages/alchemy/src/Docker/Docker.ts @@ -16,6 +16,7 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import type { ScopedPlanStatusSession } from "../Cli/Cli.ts"; +import { createPhysicalName } from "../PhysicalName.ts"; export class Docker extends Context.Service< Docker, @@ -493,6 +494,20 @@ export const DockerLive = Layer.effect( }), ); +export const dockerPhysicalName = ( + id: string, + props: { name?: string } | undefined, + instanceId: string, +) => + props?.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + instanceId, + maxLength: 128, + lowercase: true, + }); + /** Constructs a PlatformError from a command execution result. */ const systemError = (input: { _tag: SystemErrorTag; diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index f67e1acc10..2e2a8db603 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -1,12 +1,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { hashDirectory, type MemoOptions } from "../Command/Memo.ts"; -import { deepEqual, isResolved } from "../Diff.ts"; -import { createPhysicalName } from "../PhysicalName.ts"; +import * as Artifacts from "../Artifacts.ts"; +import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./Docker.ts"; +import { Docker, dockerPhysicalName } from "./Docker.ts"; +import type { Providers } from "./Providers.ts"; import { type ImageRegistry, parseCreatedAt, @@ -14,7 +14,6 @@ import { repositoryFromImageRef, withRegistryHost, } from "./Registry.ts"; -import type { Providers } from "./Providers.ts"; export interface DockerBuildOptions { /** @@ -41,8 +40,6 @@ export interface DockerBuildOptions { cacheTo?: string[]; /** Additional Docker build options. */ options?: string[]; - /** Files included in the build-context hash used for rebuild decisions. */ - memo?: MemoOptions; } export interface ImageProps { @@ -74,8 +71,6 @@ export interface Image extends Resource< tag: string; /** Build timestamp in milliseconds since epoch. */ builtAt: number; - /** Hash of the build-context files. */ - contextHash?: string; }, never, Providers @@ -132,17 +127,38 @@ export const ImageProvider = () => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const docker = yield* Docker; - const context = yield* Effect.context< - FileSystem.FileSystem | Path.Path - >(); - const contextHash = Effect.fn(function* (props: ImageProps) { - const cwd = yield* Effect.sync(() => process.cwd()); - return yield* hashDirectory({ - cwd: props.build.context ?? cwd, - memo: props.build.memo, + const buildAndInspectImage = Effect.fn(function* ( + id: string, + props: ImageProps, + instanceId: string, + ) { + const name = yield* dockerPhysicalName(id, props, instanceId); + const tag = props.tag ?? "latest"; + const ref = `${name}:${tag}`; + + const paths = yield* resolveBuildPaths(props.build); + yield* docker.image.build({ + tag: ref, + context: paths.context, + file: paths.dockerfile, + platform: props.build.platform, + target: props.build.target, + "build-arg": props.build.args, + "cache-from": props.build.cacheFrom, + "cache-to": props.build.cacheTo, + args: props.build.options, }); - }, Effect.provide(context)); + + // Read the freshly built image's id and creation time straight from + // Docker rather than synthesizing a wall-clock timestamp. + return { + name, + tag, + image: yield* docker.image.inspect(ref), + ref, + }; + }, Artifacts.cached("build")); const resolveBuildPaths = Effect.fn(function* ( build: DockerBuildOptions, @@ -168,8 +184,11 @@ export const ImageProvider = () => return Image.Provider.of({ list: () => Effect.succeed([]), read: Effect.fn(function* ({ id, instanceId, olds, output }) { - const props = yield* withResolvedName(id, olds, instanceId); - const ref = output?.imageRef ?? localImageRef(id, props); + const ref = + output?.imageRef ?? + (yield* dockerPhysicalName(id, olds, instanceId).pipe( + Effect.map((name) => `${name}:${olds.tag ?? "latest"}`), + )); const image = yield* docker.image .inspect(ref) .pipe( @@ -187,69 +206,43 @@ export const ImageProvider = () => repoDigest: output?.repoDigest, tag: output?.tag ?? olds.tag ?? "latest", builtAt: output?.builtAt ?? parseCreatedAt(image.Created), - contextHash: output?.contextHash, }; }), - diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) { - if (!isResolved(news)) return undefined; - if (!output) return undefined; - const props = yield* withResolvedName(id, news, instanceId); - const nextHash = yield* contextHash(news); - if ( - !deepEqual(comparableProps(olds), comparableProps(news)) || - output.imageRef !== desiredImageRef(id, props) || - output.contextHash !== nextHash - ) { - return { action: "update" as const }; + diff: Effect.fn(function* ({ id, instanceId, news, output }) { + if (!isResolved(news) || !output) return undefined; + const { image } = yield* buildAndInspectImage(id, news, instanceId); + if (output?.imageId !== image.Id) { + return { action: "update" }; } }), reconcile: Effect.fn(function* ({ id, instanceId, news, session }) { - const props = yield* withResolvedName(id, news, instanceId); - const tag = props.tag ?? "latest"; - const ref = localImageRef(id, props); - - const paths = yield* resolveBuildPaths(props.build); - yield* session.note(`Building Docker image: ${ref}`); - yield* docker.image.build( - { - tag: ref, - context: paths.context, - file: paths.dockerfile, - platform: props.build.platform, - target: props.build.target, - "build-arg": props.build.args, - "cache-from": props.build.cacheFrom, - "cache-to": props.build.cacheTo, - args: props.build.options, - }, - session, + const { name, tag, image, ref } = yield* buildAndInspectImage( + id, + news, + instanceId, ); - const nextContextHash = yield* contextHash(props); - - // Read the freshly built image's id and creation time straight from - // Docker rather than synthesizing a wall-clock timestamp. - const inspected = yield* docker.image.inspect(ref); let repoDigest: string | undefined; - if (props.registry && !props.skipPush) { + let targetImageRef: string = ref; + if (news.registry && !news.skipPush) { yield* session.note( - `Pushing image to registry "${props.registry.server}"`, + `Pushing image to registry "${news.registry.server}"`, ); + targetImageRef = withRegistryHost(ref, news.registry); repoDigest = yield* docker.image - .push(ref, props.registry) + .push(ref, news.registry) .pipe( Effect.map((result) => parseRepoDigest(ref, result.stdout)), ); } return { - name: repositoryFromImageRef(ref), - imageRef: ref, - imageId: inspected.Id, + name, + imageRef: targetImageRef, + imageId: image.Id, repoDigest, tag, - builtAt: parseCreatedAt(inspected.Created), - contextHash: nextContextHash, + builtAt: parseCreatedAt(image.Created), }; }), delete: Effect.fn(({ output }) => @@ -266,43 +259,3 @@ export const ImageProvider = () => }); }), ); - -export const localImageRef = (id: string, props: ImageProps): string => - `${props.name ?? id}:${props.tag ?? "latest"}`; - -export const desiredImageRef = (id: string, props: ImageProps): string => { - const ref = localImageRef(id, props); - return props.registry && !props.skipPush - ? withRegistryHost(ref, props.registry) - : ref; -}; - -/** - * Resolves the built image's repository name. When a build has no explicit - * `name`, an engine physical name is generated (stack + stage + logical id + - * instance id) just like other resources, then carried back on `props.name` so - * the synchronous ref helpers stay deterministic across reconcile/diff/read. - */ -const withResolvedName = (id: string, props: ImageProps, instanceId: string) => - props.name === undefined - ? createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }).pipe(Effect.map((name): ImageProps => ({ ...props, name }))) - : Effect.succeed(props); - -const comparableProps = (props: ImageProps | undefined) => - props - ? { - ...props, - registry: props.registry - ? { - server: props.registry.server, - username: props.registry.username, - password: props.registry.password, - } - : undefined, - } - : undefined; diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index fb8b35656a..8c61e23d79 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -3,10 +3,9 @@ import * as Equal from "effect/Equal"; import { identity } from "effect/Function"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; -import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./Docker.ts"; +import { Docker, dockerPhysicalName } from "./Docker.ts"; import type { Providers } from "./Providers.ts"; export interface NetworkProps { @@ -80,7 +79,7 @@ export const NetworkProvider = () => return Network.Provider.of({ list: () => Effect.succeed([]), read: Effect.fn(({ id, instanceId, olds, output }) => - networkName(id, olds ?? {}, instanceId).pipe( + dockerPhysicalName(id, olds, instanceId).pipe( Effect.flatMap(docker.network.inspect), Effect.map(toNetworkAttributes), Effect.map(output ? identity : Unowned), @@ -138,26 +137,12 @@ export const NetworkProvider = () => }), ); -const networkName = ( - id: string, - props: NetworkProps | undefined, - instanceId: string, -) => - props?.name - ? Effect.succeed(props.name) - : createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }); - const makeNetworkArgs = ( id: string, props: NetworkProps | undefined, instanceId: string, ) => - networkName(id, props, instanceId).pipe( + dockerPhysicalName(id, props, instanceId).pipe( Effect.map( (name): Parameters[0] => ({ name, diff --git a/packages/alchemy/src/Docker/RemoteImage.ts b/packages/alchemy/src/Docker/RemoteImage.ts index 0572e0b01b..1118c8a8e6 100644 --- a/packages/alchemy/src/Docker/RemoteImage.ts +++ b/packages/alchemy/src/Docker/RemoteImage.ts @@ -3,6 +3,7 @@ import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { Docker } from "./Docker.ts"; +import type { Providers } from "./Providers.ts"; import { type ImageRegistry, parseCreatedAt, @@ -10,7 +11,6 @@ import { repositoryFromImageRef, withRegistryHost, } from "./Registry.ts"; -import type { Providers } from "./Providers.ts"; export interface RemoteImageProps { /** Docker image name to pull, without tag. */ @@ -197,7 +197,7 @@ export const RemoteImageProvider = () => ); /** The reference the image is pulled from. */ -export const remoteImageRef = (props: RemoteImageProps): string => +const remoteImageRef = (props: RemoteImageProps): string => `${props.name}:${props.tag ?? "latest"}`; const targetTag = (props: RemoteImageProps): string => diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index 3ac8079ba6..de122bb022 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -3,10 +3,9 @@ import * as Equal from "effect/Equal"; import { identity } from "effect/Function"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; -import { createPhysicalName } from "../PhysicalName.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { Docker } from "./Docker.ts"; +import { Docker, dockerPhysicalName } from "./Docker.ts"; import type { Providers } from "./Providers.ts"; export interface VolumeLabel { @@ -101,7 +100,7 @@ export const VolumeProvider = () => return Volume.Provider.of({ list: () => Effect.succeed([]), read: Effect.fn(({ id, instanceId, olds, output }) => - volumeName(id, olds ?? {}, instanceId).pipe( + dockerPhysicalName(id, olds, instanceId).pipe( Effect.flatMap(docker.volume.inspect), Effect.map(toVolumeAttributes), Effect.map(output ? identity : Unowned), @@ -146,18 +145,8 @@ export const VolumeProvider = () => }), ); -const volumeName = (id: string, props: VolumeProps, instanceId: string) => - props.name - ? Effect.succeed(props.name) - : createPhysicalName({ - id, - instanceId, - maxLength: 128, - lowercase: true, - }); - const makeVolumeArgs = (id: string, props: VolumeProps, instanceId: string) => - volumeName(id, props, instanceId).pipe( + dockerPhysicalName(id, props, instanceId).pipe( Effect.map( (name): Parameters[0] => ({ name, diff --git a/packages/alchemy/test/Docker/Image.test.ts b/packages/alchemy/test/Docker/Image.test.ts index e6f2ccbd68..ce0275fc4d 100644 --- a/packages/alchemy/test/Docker/Image.test.ts +++ b/packages/alchemy/test/Docker/Image.test.ts @@ -1,14 +1,10 @@ -import { hashDirectory } from "@/Command/Memo"; import * as Docker from "@/Docker"; -import { desiredImageRef, localImageRef } from "@/Docker/Image"; -import * as Provider from "@/Provider"; import { inMemoryState } from "@/State"; import * as Test from "@/Test/Vitest"; -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Redacted from "effect/Redacted"; import { spawnSync } from "node:child_process"; const dockerDaemonOk = @@ -20,94 +16,6 @@ const { test } = Test.make({ adopt: true, }); -const registry = { - server: "ghcr.io", - username: "octocat", - password: Redacted.make("token"), -}; - -describe("ref helpers", () => { - it("localImageRef falls back to the logical id for the repository", () => { - expect(localImageRef("app-image", { build: { context: "." } })).toBe( - "app-image:latest", - ); - }); - - it("localImageRef uses the explicit name and tag", () => { - expect( - localImageRef("app-image", { - build: { context: "." }, - name: "acme/app", - tag: "v1", - }), - ).toBe("acme/app:v1"); - }); - - it("desiredImageRef prefixes the registry host when pushing", () => { - expect( - desiredImageRef("app-image", { - build: { context: "." }, - name: "acme/app", - tag: "v1", - registry, - }), - ).toBe("ghcr.io/acme/app:v1"); - }); - - it("desiredImageRef keeps the local ref when not pushing", () => { - expect( - desiredImageRef("app-image", { - build: { context: "." }, - name: "acme/app", - tag: "v1", - registry, - skipPush: true, - }), - ).toBe("acme/app:v1"); - }); -}); - -test.provider("diff does not flag a spurious update when nothing changed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const imageProvider = yield* Provider.findProvider(Docker.Image); - const buildDir = yield* fs.makeTempDirectoryScoped({ - prefix: "alchemy-docker-canary-", - }); - yield* fs.writeFileString( - path.join(buildDir, "Dockerfile"), - "FROM scratch\n", - ); - const contextHash = yield* hashDirectory({ cwd: buildDir }); - const imageProps = { - name: "acme/app", - tag: "latest", - build: { context: buildDir }, - registry, - }; - // Registry-host prefixing must not look like a change: - // desiredImageRef("acme/app:latest") resolves to "ghcr.io/acme/app:latest". - const imageDiff = yield* imageProvider.diff!({ - id: "app-image", - instanceId: "instance", - olds: imageProps, - news: imageProps, - oldBindings: [], - newBindings: [], - output: { - name: "ghcr.io/acme/app", - imageRef: "ghcr.io/acme/app:latest", - imageId: "sha256:0", - tag: "latest", - builtAt: 0, - contextHash, - }, - }); - expect(imageDiff).toBeUndefined(); - }), -); - describe("Docker.Image", { concurrent: false }, () => { test.provider.skipIf(!dockerDaemonOk)( "builds a tiny Dockerfile with an auto-generated name", @@ -131,9 +39,40 @@ describe("Docker.Image", { concurrent: false }, () => { ); expect(image.imageRef.endsWith(":latest")).toBe(true); expect(image.imageId.length).toBeGreaterThan(0); - expect(image.contextHash?.length).toBeGreaterThan(0); }), - { timeout: 120000 }, + ); + + test.provider("updates when the build context changes", (stack) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "alchemy-docker-canary-", + }); + yield* fs.writeFileString( + path.join(root, "Dockerfile"), + "FROM scratch\n", + ); + yield* fs.writeFileString( + path.join(root, "Dockerfile"), + "FROM scratch\nLABEL alchemy.test=1\n", + ); + + const makeStack = Docker.Image("tiny-image", { + tag: "latest", + build: { context: root }, + }); + + yield* stack.deploy(makeStack); + const plan1 = yield* stack.plan(makeStack); + expect(plan1.resources["tiny-image"]).toMatchObject({ action: "noop" }); + yield* fs.writeFileString( + path.join(root, "Dockerfile"), + "FROM scratch\nLABEL alchemy.test=2\n", + ); + const plan2 = yield* stack.plan(makeStack); + expect(plan2.resources["tiny-image"]).toMatchObject({ action: "update" }); + }), ); test.provider.skipIf(!dockerDaemonOk)( @@ -191,7 +130,6 @@ describe("Docker.Image", { concurrent: false }, () => { ); expect(second.imageRef).toBe(first.imageRef); - expect(second.contextHash).not.toBe(first.contextHash); }), { timeout: 120000 }, ); From 1d3e2d6c2ca4ed56db45179d08d486e44c63d9ed Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 22:19:56 -0400 Subject: [PATCH 22/25] mv helper --- .../alchemy/test/Docker/RemoteImage.test.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/alchemy/test/Docker/RemoteImage.test.ts b/packages/alchemy/test/Docker/RemoteImage.test.ts index afcf830693..b1f0bbc91d 100644 --- a/packages/alchemy/test/Docker/RemoteImage.test.ts +++ b/packages/alchemy/test/Docker/RemoteImage.test.ts @@ -14,25 +14,6 @@ import { describe } from "vitest"; const dockerDaemonOk = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; -const freeHostPort = Effect.promise( - () => - new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = - typeof address === "object" && address ? address.port : undefined; - server.close((error) => { - if (error) reject(error); - else if (port) resolve(port); - else reject(new Error("Failed to allocate a free host port")); - }); - }); - }), -); - const { test } = Test.make({ providers: Docker.providers(), state: inMemoryState(), @@ -179,3 +160,22 @@ describe("Docker.RemoteImage", { concurrent: false }, () => { }), ); }); + +const freeHostPort = Effect.promise( + () => + new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : undefined; + server.close((error) => { + if (error) reject(error); + else if (port) resolve(port); + else reject(new Error("Failed to allocate a free host port")); + }); + }); + }), +); From ce185578ebf8d5ed9c342000d5c609f73aa988f9 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 22:33:13 -0400 Subject: [PATCH 23/25] relocate helpers --- .../alchemy/test/Docker/Container.test.ts | 43 ++++--------------- packages/alchemy/test/Docker/Image.test.ts | 14 ++---- packages/alchemy/test/Docker/Network.test.ts | 13 +++--- .../alchemy/test/Docker/RemoteImage.test.ts | 43 +++++-------------- packages/alchemy/test/Docker/Runtime.ts | 28 ++++++++++++ packages/alchemy/test/Docker/Volume.test.ts | 26 +++++------ 6 files changed, 68 insertions(+), 99 deletions(-) create mode 100644 packages/alchemy/test/Docker/Runtime.ts diff --git a/packages/alchemy/test/Docker/Container.test.ts b/packages/alchemy/test/Docker/Container.test.ts index 1e9f048a7d..25e28c4340 100644 --- a/packages/alchemy/test/Docker/Container.test.ts +++ b/packages/alchemy/test/Docker/Container.test.ts @@ -4,12 +4,8 @@ import { inMemoryState } from "@/State"; import * as Test from "@/Test/Vitest"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { spawnSync } from "node:child_process"; -import { createServer } from "node:net"; import { describe } from "vitest"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; +import { findAvailablePort, isDockerReady } from "./Runtime.ts"; const { test } = Test.make({ providers: Docker.providers(), @@ -41,12 +37,12 @@ test.provider("diff replaces a container when its image changes", () => ); describe("Docker.Container", { concurrent: false }, () => { - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "publishes and inspects bound host ports", (stack) => Effect.gen(function* () { const docker = yield* Docker.Docker; - const hostPort = yield* freeHostPort; + const hostPort = yield* findAvailablePort(); // No explicit name: rely on the engine-generated physical name. const container = yield* stack.deploy( Docker.Container("nginx-container", { @@ -68,7 +64,7 @@ describe("Docker.Container", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "creates a stopped container when start is false", (stack) => Effect.gen(function* () { @@ -83,7 +79,7 @@ describe("Docker.Container", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "updates network aliases without replacing the container", (stack) => Effect.gen(function* () { @@ -114,12 +110,12 @@ describe("Docker.Container", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "replaces the container when published ports change", (stack) => Effect.gen(function* () { - const firstPort = yield* freeHostPort; - const secondPort = yield* freeHostPort; + const firstPort = yield* findAvailablePort(); + const secondPort = yield* findAvailablePort(); const first = yield* stack.deploy( Docker.Container("ported-container", { image: "nginx:alpine", @@ -137,26 +133,3 @@ describe("Docker.Container", { concurrent: false }, () => { }), ); }); - -const freeHostPort = Effect.promise( - () => - new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = - typeof address === "object" && address ? address.port : undefined; - server.close((error) => { - if (error) { - reject(error); - } else if (port) { - resolve(port); - } else { - reject(new Error("Failed to allocate a free host port")); - } - }); - }); - }), -); diff --git a/packages/alchemy/test/Docker/Image.test.ts b/packages/alchemy/test/Docker/Image.test.ts index ce0275fc4d..eb95a025af 100644 --- a/packages/alchemy/test/Docker/Image.test.ts +++ b/packages/alchemy/test/Docker/Image.test.ts @@ -5,19 +5,15 @@ import { describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { spawnSync } from "node:child_process"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; +import { isDockerReady } from "./Runtime.ts"; const { test } = Test.make({ providers: Docker.providers(), state: inMemoryState(), - adopt: true, }); describe("Docker.Image", { concurrent: false }, () => { - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "builds a tiny Dockerfile with an auto-generated name", (stack) => Effect.gen(function* () { @@ -75,7 +71,7 @@ describe("Docker.Image", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "builds with an explicit repository name and tag", (stack) => Effect.gen(function* () { @@ -99,10 +95,9 @@ describe("Docker.Image", { concurrent: false }, () => { expect(image.imageRef).toBe("alchemy-test-named:v1"); expect(image.tag).toBe("v1"); }), - { timeout: 120000 }, ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "rebuilds when the build context changes", (stack) => Effect.gen(function* () { @@ -131,6 +126,5 @@ describe("Docker.Image", { concurrent: false }, () => { expect(second.imageRef).toBe(first.imageRef); }), - { timeout: 120000 }, ); }); diff --git a/packages/alchemy/test/Docker/Network.test.ts b/packages/alchemy/test/Docker/Network.test.ts index 29b65bdc91..c5670552ef 100644 --- a/packages/alchemy/test/Docker/Network.test.ts +++ b/packages/alchemy/test/Docker/Network.test.ts @@ -6,11 +6,8 @@ import * as Test from "@/Test/Vitest"; import { expect } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import { spawnSync } from "node:child_process"; import { describe } from "vitest"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; +import { isDockerReady } from "./Runtime.ts"; const { test } = Test.make({ providers: Docker.providers(), @@ -41,7 +38,7 @@ test.provider("diff replaces a network when labels change", () => ); describe("Docker.Network", { concurrent: false }, () => { - test.provider.skipIf(!dockerDaemonOk)("creates a bridge network", (stack) => + test.provider.skipIf(!isDockerReady)("creates a bridge network", (stack) => Effect.gen(function* () { const network = yield* stack.deploy( Docker.Network("created-network", { @@ -57,7 +54,7 @@ describe("Docker.Network", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "refuses a pre-existing network unless explicitly adopted", (stack) => Effect.gen(function* () { @@ -91,7 +88,7 @@ describe("Docker.Network", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "adopts an existing same-name network with stack adoption", (stack) => Effect.gen(function* () { @@ -118,7 +115,7 @@ describe("Docker.Network", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "replaces a network when its labels change", (stack) => Effect.gen(function* () { diff --git a/packages/alchemy/test/Docker/RemoteImage.test.ts b/packages/alchemy/test/Docker/RemoteImage.test.ts index b1f0bbc91d..94115a18e2 100644 --- a/packages/alchemy/test/Docker/RemoteImage.test.ts +++ b/packages/alchemy/test/Docker/RemoteImage.test.ts @@ -7,17 +7,12 @@ import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import { spawnSync } from "node:child_process"; -import { createServer } from "node:net"; import { describe } from "vitest"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; +import { findAvailablePort, isDockerReady } from "./Runtime.ts"; const { test } = Test.make({ providers: Docker.providers(), state: inMemoryState(), - adopt: true, }); test.provider("diff pulls again unless alwaysPull is disabled", () => @@ -56,7 +51,7 @@ test.provider("diff pulls again unless alwaysPull is disabled", () => ); describe("Docker.RemoteImage", { concurrent: false }, () => { - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "pulls a Docker image reference", (stack) => Effect.gen(function* () { @@ -72,7 +67,7 @@ describe("Docker.RemoteImage", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "pulls then re-tags under a new repository", (stack) => Effect.gen(function* () { @@ -103,13 +98,13 @@ describe("Docker.RemoteImage", { concurrent: false }, () => { }), ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "pulls, re-tags, and pushes to a registry", (stack) => Effect.gen(function* () { const docker = yield* Docker.Docker; const client = yield* HttpClient.HttpClient; - const port = yield* freeHostPort; + const port = yield* findAvailablePort(); const registryName = "alchemy-test-registry"; const host = `localhost:${port}`; const targetName = `${host}/alchemy-hello`; @@ -117,10 +112,10 @@ describe("Docker.RemoteImage", { concurrent: false }, () => { const targetRef = `${targetName}:${targetTag}`; yield* Effect.addFinalizer(() => - docker.run(["rm", "-f", registryName]).pipe(Effect.ignore), - ); - yield* Effect.addFinalizer(() => - docker.image.remove([targetRef], true).pipe(Effect.ignore), + Effect.all([ + docker.run(["rm", "-f", registryName]), + docker.image.remove(targetRef, true), + ]).pipe(Effect.ignore), ); yield* docker.run([ @@ -132,6 +127,7 @@ describe("Docker.RemoteImage", { concurrent: false }, () => { `${port}:5000`, "registry:2", ]); + // Wait for the registry HTTP API to start serving before pushing. yield* client.get(`http://${host}/v2/`).pipe( Effect.retry({ @@ -160,22 +156,3 @@ describe("Docker.RemoteImage", { concurrent: false }, () => { }), ); }); - -const freeHostPort = Effect.promise( - () => - new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = - typeof address === "object" && address ? address.port : undefined; - server.close((error) => { - if (error) reject(error); - else if (port) resolve(port); - else reject(new Error("Failed to allocate a free host port")); - }); - }); - }), -); diff --git a/packages/alchemy/test/Docker/Runtime.ts b/packages/alchemy/test/Docker/Runtime.ts new file mode 100644 index 0000000000..a9ead94128 --- /dev/null +++ b/packages/alchemy/test/Docker/Runtime.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeNet from "node:net"; + +export const isDockerReady = + NodeChildProcess.spawnSync("docker", ["info"], { stdio: "ignore" }).status === + 0; + +export const findAvailablePort = () => + Effect.callback((resume) => { + const server = NodeNet.createServer(); + server.unref(); + server.on("error", (error) => resume(Effect.fail(error))); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = + typeof address === "object" && address ? address.port : undefined; + server.close((error) => { + if (error) { + resume(Effect.fail(error)); + } else if (port) { + resume(Effect.succeed(port)); + } else { + resume(Effect.fail(new Error("Failed to allocate a free host port"))); + } + }); + }); + }); diff --git a/packages/alchemy/test/Docker/Volume.test.ts b/packages/alchemy/test/Docker/Volume.test.ts index b8ea42b77b..0c73d7f323 100644 --- a/packages/alchemy/test/Docker/Volume.test.ts +++ b/packages/alchemy/test/Docker/Volume.test.ts @@ -4,16 +4,13 @@ import { inMemoryState } from "@/State"; import * as Test from "@/Test/Vitest"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { spawnSync } from "node:child_process"; import { describe } from "vitest"; - -const dockerDaemonOk = - spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0; +import { adopt, OwnedBySomeoneElse } from "../../src/AdoptPolicy.ts"; +import { isDockerReady } from "./Runtime.ts"; const { test } = Test.make({ providers: Docker.providers(), state: inMemoryState(), - adopt: true, }); test.provider("diff replaces a volume when labels change", () => @@ -40,8 +37,8 @@ test.provider("diff replaces a volume when labels change", () => }), ); -describe.sequential("Docker.Volume", () => { - test.provider.skipIf(!dockerDaemonOk)( +describe("Docker.Volume", { concurrent: false }, () => { + test.provider.skipIf(!isDockerReady)( "creates a volume with labels", (stack) => Effect.gen(function* () { @@ -62,10 +59,9 @@ describe.sequential("Docker.Volume", () => { expect(volume.labels["com.alchemy.test"]).toBe("true"); expect(volume.mountpoint?.length).toBeGreaterThan(0); }), - { timeout: 120000 }, ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "adopts an existing Docker volume", (stack) => Effect.gen(function* () { @@ -81,17 +77,22 @@ describe.sequential("Docker.Volume", () => { ); yield* docker.volume.create({ name: volumeName }); + const error = yield* stack + .deploy(Docker.Volume("existing-volume", { name: volumeName })) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(OwnedBySomeoneElse); const volume = yield* stack.deploy( - Docker.Volume("existing-volume", { name: volumeName }), + Docker.Volume("existing-volume", { name: volumeName }).pipe( + adopt(true), + ), ); expect(volume.name).toBe(volumeName); expect(volume.id).toBe(volumeName); expect(volume.driver).toBe("local"); }), - { timeout: 120000 }, ); - test.provider.skipIf(!dockerDaemonOk)( + test.provider.skipIf(!isDockerReady)( "replaces a volume when its labels change", (stack) => Effect.gen(function* () { @@ -115,6 +116,5 @@ describe.sequential("Docker.Volume", () => { expect(second.id).not.toBe(first.id); expect(second.labels.generation).toBe("2"); }), - { timeout: 120000 }, ); }); From 29acf6ab07764230d4b3bc8dc69c7b2ec265ea03 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 25 Jun 2026 22:44:04 -0400 Subject: [PATCH 24/25] fix import --- packages/alchemy/test/Docker/Volume.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/alchemy/test/Docker/Volume.test.ts b/packages/alchemy/test/Docker/Volume.test.ts index 0c73d7f323..a84b2aa6f8 100644 --- a/packages/alchemy/test/Docker/Volume.test.ts +++ b/packages/alchemy/test/Docker/Volume.test.ts @@ -1,3 +1,4 @@ +import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy.ts"; import * as Docker from "@/Docker"; import * as Provider from "@/Provider"; import { inMemoryState } from "@/State"; @@ -5,7 +6,6 @@ import * as Test from "@/Test/Vitest"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { describe } from "vitest"; -import { adopt, OwnedBySomeoneElse } from "../../src/AdoptPolicy.ts"; import { isDockerReady } from "./Runtime.ts"; const { test } = Test.make({ From ba6d340085062093538b3f7d310a6f2fe67212d5 Mon Sep 17 00:00:00 2001 From: John Royal Date: Fri, 26 Jun 2026 11:42:17 -0400 Subject: [PATCH 25/25] claude pr feedback fixes --- packages/alchemy/src/Docker/Container.ts | 51 ++++++++++++-------- packages/alchemy/src/Docker/Docker.ts | 6 ++- packages/alchemy/src/Docker/Image.ts | 8 +++- packages/alchemy/src/Docker/Network.ts | 55 +++++++++++++--------- packages/alchemy/src/Docker/Volume.ts | 59 +++++++++++++++--------- packages/alchemy/src/Tags.ts | 12 +++++ 6 files changed, 127 insertions(+), 64 deletions(-) diff --git a/packages/alchemy/src/Docker/Container.ts b/packages/alchemy/src/Docker/Container.ts index a2f2af248b..50cb9f59a1 100644 --- a/packages/alchemy/src/Docker/Container.ts +++ b/packages/alchemy/src/Docker/Container.ts @@ -1,13 +1,13 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; -import { identity } from "effect/Function"; import type { PlatformError } from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; +import { createInternalTags, hasAlchemyTags } from "../Tags.ts"; import { Docker, dockerPhysicalName } from "./Docker.ts"; import type { Providers } from "./Providers.ts"; @@ -172,14 +172,12 @@ export const Container = Resource("Docker.Container"); */ export const inspectContainer = ( name: string, -): Effect.Effect< - Exclude, - PlatformError, - Docker -> => +): Effect.Effect => Docker.pipe( Effect.flatMap((docker) => docker.container.inspect(name)), - Effect.map((container) => toContainerAttributes(container, undefined!)), + Effect.map((container) => + toContainerAttributes(container, container.Config.Image), + ), ); export const ContainerProvider = () => @@ -235,17 +233,28 @@ export const ContainerProvider = () => list: () => Effect.succeed([]), read: Effect.fn(function* ({ id, instanceId, olds, output }) { const name = yield* dockerPhysicalName(id, olds, instanceId); - return yield* docker.container.inspect(name).pipe( - Effect.map((info) => - toContainerAttributes(info, normalizeImageRef(olds.image)), - ), - Effect.map(output ? identity : Unowned), - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.undefined, - ), + const info = yield* docker.container + .inspect(name) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + if (!info) return undefined; + const attrs = toContainerAttributes( + info, + normalizeImageRef(olds.image), + ); + if (output) return attrs; + // Without prior state, only adopt a container that carries our + // branding; anything else is foreign and gated behind `--adopt`. + const owned = yield* hasAlchemyTags( + id, + info.Config.Labels ?? undefined, ); + return owned ? attrs : Unowned(attrs); }), diff: Effect.fn(function* ({ id, instanceId, news, olds }) { if (!isResolved(news)) return undefined; @@ -277,6 +286,8 @@ export const ContainerProvider = () => yield* reconcileNetworks(live, news); if (news.start && live.State.Status !== "running") { yield* docker.container.start(live.Id); + } else if (!news.start && live.State.Status === "running") { + yield* docker.container.stop(live.Id); } return yield* docker.container .inspect(live.Id) @@ -285,7 +296,11 @@ export const ContainerProvider = () => ); } - const { stdout: containerId } = yield* docker.container.create(args); + const internalTags = yield* createInternalTags(id); + const { stdout: containerId } = yield* docker.container.create({ + ...args, + label: internalTags, + }); yield* Effect.forEach( news.networks ?? [], (network) => diff --git a/packages/alchemy/src/Docker/Docker.ts b/packages/alchemy/src/Docker/Docker.ts index 008c6ede8d..663d1096fe 100644 --- a/packages/alchemy/src/Docker/Docker.ts +++ b/packages/alchemy/src/Docker/Docker.ts @@ -51,6 +51,7 @@ export class Docker extends Context.Service< "health-start-interval": string | undefined; p: Array | undefined; command: Array | undefined; + label?: Record; }) => Effect.Effect; /** Inspects a container. */ readonly inspect: ( @@ -182,6 +183,7 @@ export declare namespace Docker { Image: string; Cmd: string[] | null; Env: string[] | null; + Labels: Record | null; Healthcheck?: { Test: string[] | null; Interval?: number; @@ -371,7 +373,7 @@ export const DockerLive = Layer.effect( ), ), container: { - create: ({ image, env, ...options }) => + create: ({ image, env, command, ...options }) => run( [ "container", @@ -381,7 +383,7 @@ export const DockerLive = Layer.effect( env: env ? Object.keys(env) : undefined, }), image, - ...(options.command ?? []), + ...(command ?? []), ], env, ), diff --git a/packages/alchemy/src/Docker/Image.ts b/packages/alchemy/src/Docker/Image.ts index 2e2a8db603..1ac24c3c25 100644 --- a/packages/alchemy/src/Docker/Image.ts +++ b/packages/alchemy/src/Docker/Image.ts @@ -43,14 +43,18 @@ export interface DockerBuildOptions { } export interface ImageProps { + /** + * Repository/name for the built image. + * + * @default Generated from stack, stage, logical id, and instance id. + */ + name?: string; /** Image tag. @default "latest" */ tag?: string; /** Registry credentials for push. */ registry?: ImageRegistry; /** Skip registry push even when `registry` is set. @default false */ skipPush?: boolean; - /** Repository/name for the built image. @default Logical id */ - name?: string; /** Docker build configuration. */ build: DockerBuildOptions; } diff --git a/packages/alchemy/src/Docker/Network.ts b/packages/alchemy/src/Docker/Network.ts index 8c61e23d79..ace75eaf78 100644 --- a/packages/alchemy/src/Docker/Network.ts +++ b/packages/alchemy/src/Docker/Network.ts @@ -1,10 +1,14 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; -import { identity } from "effect/Function"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; +import { + createInternalTags, + hasAlchemyTags, + stripInternalTags, +} from "../Tags.ts"; import { Docker, dockerPhysicalName } from "./Docker.ts"; import type { Providers } from "./Providers.ts"; @@ -78,18 +82,25 @@ export const NetworkProvider = () => return Network.Provider.of({ list: () => Effect.succeed([]), - read: Effect.fn(({ id, instanceId, olds, output }) => - dockerPhysicalName(id, olds, instanceId).pipe( - Effect.flatMap(docker.network.inspect), - Effect.map(toNetworkAttributes), - Effect.map(output ? identity : Unowned), - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.undefined, - ), - ), - ), + read: Effect.fn(function* ({ id, instanceId, olds, output }) { + const name = yield* dockerPhysicalName(id, olds, instanceId); + const info = yield* docker.network + .inspect(name) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + if (!info) return undefined; + const attrs = toNetworkAttributes(info); + if (output) return attrs; + // Without prior state, only adopt a network that carries our branding; + // anything else is foreign and gated behind `--adopt`. + const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined); + return owned ? attrs : Unowned(attrs); + }), diff: Effect.fn(function* ({ id, output, instanceId, news }) { if (!isResolved(news) || !output) return undefined; const args = yield* makeNetworkArgs(id, news, instanceId); @@ -97,11 +108,12 @@ export const NetworkProvider = () => output.name !== args.name || output.driver !== args.driver || output.enableIPv6 !== args.ipv6 || - !Equal.equals(output.labels, args.label) + // Compare only user labels; internal `alchemy::*` branding lives on + // the observed network but must not drive replacement. + !Equal.equals(stripInternalTags(output.labels), args.label ?? {}) ) { return { action: "replace", deleteFirst: true }; } - return { action: "noop" }; }), reconcile: Effect.fn(function* ({ output, id, instanceId, news }) { if (output) { @@ -115,12 +127,13 @@ export const NetworkProvider = () => ); if (refreshed) return refreshed; } - return yield* makeNetworkArgs(id, news, instanceId).pipe( - Effect.flatMap(docker.network.create), - Effect.map((result) => result.stdout), - Effect.flatMap((createdId) => docker.network.inspect(createdId)), - Effect.map(toNetworkAttributes), - ); + const args = yield* makeNetworkArgs(id, news, instanceId); + const internalTags = yield* createInternalTags(id); + const { stdout: createdId } = yield* docker.network.create({ + ...args, + label: { ...internalTags, ...args.label }, + }); + return toNetworkAttributes(yield* docker.network.inspect(createdId)); }), delete: Effect.fn(({ output }) => docker.network diff --git a/packages/alchemy/src/Docker/Volume.ts b/packages/alchemy/src/Docker/Volume.ts index de122bb022..952372a0cd 100644 --- a/packages/alchemy/src/Docker/Volume.ts +++ b/packages/alchemy/src/Docker/Volume.ts @@ -1,10 +1,14 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; -import { identity } from "effect/Function"; import { Unowned } from "../AdoptPolicy.ts"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; +import { + createInternalTags, + hasAlchemyTags, + stripInternalTags, +} from "../Tags.ts"; import { Docker, dockerPhysicalName } from "./Docker.ts"; import type { Providers } from "./Providers.ts"; @@ -99,18 +103,25 @@ export const VolumeProvider = () => return Volume.Provider.of({ list: () => Effect.succeed([]), - read: Effect.fn(({ id, instanceId, olds, output }) => - dockerPhysicalName(id, olds, instanceId).pipe( - Effect.flatMap(docker.volume.inspect), - Effect.map(toVolumeAttributes), - Effect.map(output ? identity : Unowned), - Effect.catchReason( - "PlatformError", - "NotFound", - () => Effect.undefined, - ), - ), - ), + read: Effect.fn(function* ({ id, instanceId, olds, output }) { + const name = yield* dockerPhysicalName(id, olds, instanceId); + const info = yield* docker.volume + .inspect(name) + .pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.undefined, + ), + ); + if (!info) return undefined; + const attrs = toVolumeAttributes(info); + if (output) return attrs; + // Without prior state, only adopt a volume that carries our branding; + // anything else is foreign and gated behind `--adopt`. + const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined); + return owned ? attrs : Unowned(attrs); + }), diff: Effect.fn(function* ({ id, instanceId, output, news }) { if (!isResolved(news)) return undefined; const args = yield* makeVolumeArgs(id, news, instanceId); @@ -118,18 +129,24 @@ export const VolumeProvider = () => output?.name !== args.name || output?.driver !== args.driver || !Equal.equals(output?.driverOpts ?? {}, args.opt ?? {}) || - !Equal.equals(output?.labels ?? {}, args.label ?? {}) + // Compare only user labels; internal `alchemy::*` branding lives on + // the observed volume but must not drive replacement. + !Equal.equals(stripInternalTags(output?.labels), args.label ?? {}) ) { return { action: "replace" as const, deleteFirst: true }; } }), - reconcile: Effect.fn(({ id, instanceId, news }) => - makeVolumeArgs(id, news, instanceId).pipe( - Effect.flatMap(docker.volume.create), - Effect.flatMap((result) => docker.volume.inspect(result.stdout)), - Effect.map(toVolumeAttributes), - ), - ), + reconcile: Effect.fn(function* ({ id, instanceId, news }) { + const args = yield* makeVolumeArgs(id, news, instanceId); + const internalTags = yield* createInternalTags(id); + const result = yield* docker.volume.create({ + ...args, + label: { ...internalTags, ...args.label }, + }); + return toVolumeAttributes( + yield* docker.volume.inspect(result.stdout), + ); + }), delete: Effect.fn(({ output }) => docker.volume .remove(output.name) diff --git a/packages/alchemy/src/Tags.ts b/packages/alchemy/src/Tags.ts index c0b93ae0c3..921abfc742 100644 --- a/packages/alchemy/src/Tags.ts +++ b/packages/alchemy/src/Tags.ts @@ -48,6 +48,18 @@ export const createInternalTags = Effect.fn(function* (id: string) { }; }); +/** + * Strips the internal `alchemy::*` ownership tags from a tag/label map, leaving + * only the user-facing entries. Useful when diffing observed cloud state (which + * carries the internal branding) against the user's desired tags. + */ +export const stripInternalTags = ( + tags: Record | null | undefined, +): Record => + Object.fromEntries( + Object.entries(tags ?? {}).filter(([key]) => !key.startsWith("alchemy::")), + ); + /** * Creates AWS-compatible tag filters for finding resources by alchemy tags. * Use with AWS describe APIs that accept Filter parameters.