Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 18 additions & 32 deletions packages/alchemy/src/Cloudflare/Containers/ContainerBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ import { createPhysicalName } from "../../PhysicalName.ts";
import { Self } from "../../Self.ts";
import { Stack } from "../../Stack.ts";
import { sha256Object } from "../../Util/sha256.ts";
import type {
ContainerApplication,
ContainerApplicationProps,
} from "./ContainerApplication.ts";
import type { ContainerApplicationProps } from "./ContainerApplication.ts";

/**
* Fold the runtime-context `env` map (populated by `Binding.Service`s and
Expand All @@ -44,35 +41,24 @@ import type {
*
* Explicit `props.environmentVariables` win on a name collision.
*/
export const foldEnvIntoEnvironmentVariables = (
export const makeContainerEnv = (
props: ContainerApplicationProps,
accountId?: string,
): ContainerApplication.EnvironmentVariable[] | undefined => {
const explicitNames = new Set(
(props.environmentVariables ?? []).map((e) => e.name),
);
const environmentVariables: ContainerApplication.EnvironmentVariable[] = [
...(props.environmentVariables ?? []),
...(accountId !== undefined &&
!explicitNames.has("ALCHEMY_CLOUDFLARE_ACCOUNT_ID")
? [{ name: "ALCHEMY_CLOUDFLARE_ACCOUNT_ID", value: accountId }]
: []),
...Object.entries(props.env ?? {})
.filter(
([name, value]) =>
value !== undefined &&
!Output.isOutput(value) &&
!explicitNames.has(name) &&
name !== "ALCHEMY_CLOUDFLARE_ACCOUNT_ID",
)
.map(([name, value]) => ({
name,
value: Redacted.isRedacted(value)
? Redacted.value(value as Redacted.Redacted<string>)
: (value as string),
})),
];
return environmentVariables.length > 0 ? environmentVariables : undefined;
accountId: string,
) => {
const env: Record<string, string | Redacted.Redacted<string>> = {};
for (const [name, value] of Object.entries(props.env ?? {})) {
if (Output.isOutput(value) || value === undefined) {
continue;
}
env[name] = value;
}
for (const value of props.environmentVariables ?? []) {
env[value.name] = value.value;
}
if (!env.ALCHEMY_CLOUDFLARE_ACCOUNT_ID) {
env.ALCHEMY_CLOUDFLARE_ACCOUNT_ID = accountId;
}
return env;
};

/**
Expand Down
48 changes: 32 additions & 16 deletions packages/alchemy/src/Cloudflare/Containers/ContainerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Containers from "@distilled.cloud/cloudflare/containers";
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 * as Schedule from "effect/Schedule";
import { Unowned } from "../../AdoptPolicy.ts";
import { AlchemyContext } from "../../AlchemyContext.ts";
Expand All @@ -24,7 +25,7 @@ import {
buildFinalDockerfile,
bundleContainerProgram,
createContainerApplicationName,
foldEnvIntoEnvironmentVariables,
makeContainerEnv,
} from "./ContainerBundle.ts";
import { ContainerPlatform } from "./ContainerPlatform.ts";

Expand Down Expand Up @@ -105,8 +106,8 @@ export const LiveContainerProvider = () =>

const desiredConfiguration = (
props: ContainerApplicationProps,
env: Record<string, string | Redacted.Redacted<string>>,
imageRef: string,
accountId: string,
) =>
normalizeNulls({
image: imageRef,
Expand All @@ -127,10 +128,10 @@ export const LiveContainerProvider = () =>
vcpu: props.vcpu,
memory: props.memory,
disk: props.disk,
environmentVariables: foldEnvIntoEnvironmentVariables(
props,
accountId,
),
environmentVariables: Object.entries(env).map(([name, value]) => ({
name,
value: Redacted.isRedacted(value) ? Redacted.value(value) : value,
})),
labels: props.labels,
network: props.network,
command: props.command,
Expand Down Expand Up @@ -165,6 +166,7 @@ export const LiveContainerProvider = () =>
const computeImage = Effect.fn(function* (
id: string,
props: ContainerApplicationProps,
env: Record<string, string | Redacted.Redacted<string>>,
) {
const { accountId } = yield* yield* CloudflareEnvironment;
const name = yield* createApplicationName(id, props.name);
Expand Down Expand Up @@ -211,9 +213,9 @@ export const LiveContainerProvider = () =>
imageRef: makeRef(imageHash),
imageHash,
dev: {
context: contextDir,
dockerfile: path.join(contextDir, "Dockerfile"),
env: props.env,
context: path.relative(process.cwd(), contextDir),
dockerfile: "Dockerfile",
env,
},
};
}
Expand All @@ -229,7 +231,7 @@ export const LiveContainerProvider = () =>
imageRef: makeRef(imageHash),
imageHash,
// The local runtime pulls this image directly (no build context).
dev: { imageUri: props.image, env: props.env },
dev: { imageUri: props.image, env },
};
}

Expand All @@ -250,7 +252,11 @@ export const LiveContainerProvider = () =>
imageHash,
// The local runtime builds the user's Dockerfile against the same
// (already real-path'd) context directory.
dev: { context, dockerfile, env: props.env },
dev: {
context: path.relative(process.cwd(), context),
dockerfile: path.relative(context, dockerfile),
env,
},
};
});

Expand Down Expand Up @@ -552,11 +558,13 @@ export const LiveContainerProvider = () =>
yield* Effect.logInfo(
`Cloudflare Container update: preparing ${existing.applicationName}`,
);
const env = makeContainerEnv(news, accountId);
const { build, imageRef, imageHash, dev } = yield* computeImage(
id,
news,
env,
);
const configuration = desiredConfiguration(news, imageRef, accountId);
const configuration = desiredConfiguration(news, env, imageRef);

if (imageHash !== existing.hash?.image) {
yield* buildAndPushImage(id, news, build, imageRef, session);
Expand Down Expand Up @@ -688,8 +696,12 @@ export const LiveContainerProvider = () =>
return { action: "update", stables: ["accountId"] } as const;
}

const { imageHash } = yield* computeImage(id, news);
if (imageHash !== output.hash?.image) {
const { imageHash, dev } = yield* computeImage(
id,
news,
makeContainerEnv(news, accountId),
);
if (imageHash !== output.hash?.image || !deepEqual(dev, output.dev)) {
return { action: "update" } as const;
}
}),
Expand All @@ -700,11 +712,13 @@ export const LiveContainerProvider = () =>
);

const { accountId } = yield* yield* CloudflareEnvironment;
const env = makeContainerEnv(news, accountId);
const { build, imageRef, imageHash, dev } = yield* computeImage(
id,
news,
env,
);
const configuration = desiredConfiguration(news, imageRef, accountId);
const configuration = desiredConfiguration(news, env, imageRef);
yield* buildAndPushImage(id, news, build, imageRef, session);

// Precreate intentionally omits the Durable Object attachment so the
Expand Down Expand Up @@ -742,11 +756,13 @@ export const LiveContainerProvider = () =>
);
const durableObjects = yield* getDurableObjects(bindings);
const { accountId } = yield* yield* CloudflareEnvironment;
const env = makeContainerEnv(news, accountId);
const { build, imageRef, imageHash, dev } = yield* computeImage(
id,
news,
env,
);
const configuration = desiredConfiguration(news, imageRef, accountId);
const configuration = desiredConfiguration(news, env, imageRef);

// Observe — re-fetch the cached application to confirm it still
// exists. Cloudflare reports a deleted container application as
Expand Down
103 changes: 81 additions & 22 deletions packages/alchemy/src/Cloudflare/Containers/LocalContainerProvider.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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 * as Artifacts from "../../Artifacts.ts";
import { hashDirectory } from "../../Command/Memo.ts";
import { isResolved } from "../../Diff.ts";
import * as RpcProvider from "../../Local/RpcProvider.ts";
import { sha256Object } from "../../Util/sha256.ts";
import { normalizeNulls } from "../../Util/stable.ts";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import { generateLocalId, LOCAL_ENTRY_URL } from "../LocalRuntime.ts";
import type {
ContainerApplication,
ContainerApplicationProps,
DevContainerImage,
} from "./ContainerApplication.ts";
import {
createContainerApplicationName,
foldEnvIntoEnvironmentVariables,
makeContainerEnv,
prepareContainerBuildContext,
} from "./ContainerBundle.ts";
import { ContainerPlatform } from "./ContainerPlatform.ts";
Expand All @@ -20,9 +26,10 @@ import { ContainerPlatform } from "./ContainerPlatform.ts";
* Local (dev) provider for Cloudflare Container applications.
*
* The Docker build/run is owned by `@distilled.cloud/cloudflare-runtime`; this
* provider's only job is to bundle the container program once and materialize
* it into a stable Docker build context, then surface that context as a
* `dev: ContainerImage.Build` output so the runtime can `docker build` it.
* provider's only job is to resolve the `dev` image the runtime should use —
* a build context to `docker build` (Effect-native `main` or a user-supplied
* Dockerfile) or a remote image to `docker pull` — mirroring the three image
* variants of the live provider's `computeImage`.
*
* Everything else on the attributes is a placeholder: the real
* `applicationId`/`configuration`/etc. only exist once the live provider
Expand All @@ -35,21 +42,72 @@ export const LocalContainerProvider = () =>
ContainerPlatform,
LOCAL_ENTRY_URL,
Effect.gen(function* () {
// Bundle the container entrypoint and write it (plus the generated
// Dockerfile) into a stable build context directory. `Docker.build` in
// cloudflare-runtime reads `dockerfile` as a file path and uses
// `context` as the build context, so we point `dev` at both. The
// build-context materialization is shared with the live provider (see
// `prepareContainerBuildContext`); we only add run-scoped caching here so
// repeated reconciles in a single dev session don't re-bundle.
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

// Resolve the `dev` image plus a content hash for change detection.
// Cached per run (`Artifacts.cached`, keyed by resource id) so repeated
// diffs/reconciles in a single dev session don't re-bundle or re-hash.
//
// IMPORTANT: the cached result must stay env-free. The cache is warmed
// by `precreate`, which runs against unresolved props — binding-derived
// env values (e.g. an ApiToken's value/accountId) are still unresolved
// `Output`s there and get skipped. Caching env here would freeze that
// incomplete env and start the container without its bindings;
// `makeAttributes` attaches the freshly-computed env instead.
const prepareImage = (id: string, news: ContainerApplicationProps) =>
prepareContainerBuildContext(id, news).pipe(
Artifacts.cached("container-image"),
);
Effect.gen(function* () {
// Variant 1 — Effect-native program. Bundle `main` and write it
// (plus the generated Dockerfile) into a stable build context
// directory. `Docker.build` in cloudflare-runtime reads `dockerfile`
// as a file path and uses `context` as the build context, so we
// point `dev` at both. The build-context materialization is shared
// with the live provider (see `prepareContainerBuildContext`).
if (news.main) {
const { context, dockerfile, hash } =
yield* prepareContainerBuildContext(id, news);
return {
dev: {
context: path.relative(process.cwd(), context),
dockerfile: path.relative(context, dockerfile),
} as DevContainerImage,
hash,
};
}

// Variant 2 — pre-built remote image. The runtime pulls it
// directly; there is nothing to build.
if (news.image) {
return {
dev: { imageUri: news.image } as DevContainerImage,
hash: yield* sha256Object({ image: news.image }),
};
}

// Variant 3 — user-supplied Dockerfile + build context directory.
// The runtime builds the user's Dockerfile against the (real-path'd)
// context, exactly like the live provider's `external` variant.
const context = yield* fs.realPath(news.context ?? ".");
const dockerfile = news.dockerfile
? yield* fs.realPath(news.dockerfile)
: path.join(context, "Dockerfile");
const contextHash = yield* hashDirectory({ cwd: context });
const dockerfileContent = yield* fs.readFileString(dockerfile);
return {
dev: {
context: path.relative(process.cwd(), context),
dockerfile: path.relative(context, dockerfile),
} as DevContainerImage,
hash: yield* sha256Object({
contextHash,
dockerfile: dockerfileContent,
}),
};
}).pipe(Artifacts.cached(`container-image:${id}`));

const placeholderConfiguration = (
props: ContainerApplicationProps,
accountId: string,
env: Record<string, string | Redacted.Redacted<string>>,
) =>
normalizeNulls({
image: "local",
Expand All @@ -60,10 +118,10 @@ export const LocalContainerProvider = () =>
vcpu: props.vcpu,
memory: props.memory,
disk: props.disk,
environmentVariables: foldEnvIntoEnvironmentVariables(
props,
accountId,
),
environmentVariables: Object.entries(env).map(([name, value]) => ({
name,
value: Redacted.isRedacted(value) ? Redacted.value(value) : value,
})),
labels: props.labels,
network: props.network,
command: props.command,
Expand All @@ -83,7 +141,8 @@ export const LocalContainerProvider = () =>
output: ContainerApplication["Attributes"] | undefined;
}) {
const { accountId } = yield* yield* CloudflareEnvironment;
const { context, hash, dockerfile } = yield* prepareImage(id, news);
const env = makeContainerEnv(news, accountId);
const { dev, hash } = yield* prepareImage(id, news);
return {
applicationId: output?.applicationId ?? generateLocalId(),
applicationName: yield* createContainerApplicationName(id, news.name),
Expand All @@ -93,11 +152,11 @@ export const LocalContainerProvider = () =>
maxInstances: news.maxInstances ?? 1,
constraints: news.constraints,
affinities: news.affinities,
configuration: placeholderConfiguration(news, accountId),
configuration: placeholderConfiguration(news, env),
durableObjects: undefined,
createdAt: new Date().toISOString(),
version: 1,
dev: { context, dockerfile, env: news.env },
dev: { ...dev, env },
hash: { image: hash },
} satisfies ContainerApplication["Attributes"];
});
Expand Down
Loading
Loading