t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cloudflare/Container/ContainerApplication.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

2067 lines
82 KiB
TypeScript

import * as Cloudflare from "@/Cloudflare";
import * as Drift from "@/Drift.ts";
import * as Deploy from "@/Deploy.ts";
import * as Destroy from "@/Destroy.ts";
import { Docker, DockerLive } from "@/Docker/Docker.ts";
import * as Layer from "effect/Layer";
import * as Config from "effect/Config";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import { ExternalContainer } from "./fixtures/external/object.ts";
import ExternalContainerWorker from "./fixtures/external/worker.ts";
import MyContainerLive, {
MyContainer,
} from "./fixtures/effectful/container.ts";
import EffectfulContainerWorker from "./fixtures/effectful/worker.ts";
import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
import * as Provider from "@/Provider";
import { Stack } from "@/Stack";
import { State, type ResourceState } from "@/State";
import * as Test from "@/Test/Alchemy";
import * as Containers from "@distilled.cloud/cloudflare/containers";
import { assert, describe, expect } from "alchemy-test";
import * as Effect from "effect/Effect";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
import { applications } from "./fixtures/identity/applications.ts";
import {
publicationApplications,
sharedApplication,
recoveryApplications,
historyApplications,
} from "./fixtures/publication/applications.ts";
import { ContainerPlatform } from "@/Cloudflare/Containers/ContainerPlatform.ts";
import * as Cause from "effect/Cause";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import {
buildHistory,
supportsRegistryExport,
withBuilder,
} from "./fixtures/buildx.ts";
import { EnvBucket, RemoteContainer } from "./fixtures/remote/object.ts";
import RemoteContainerWorker from "./fixtures/remote/worker.ts";
const { test } = Test.make({
providers: Layer.mergeAll(Cloudflare.providers(), DockerLive),
});
const { test: registryCacheTest } = Test.make({
// Each deployment must consult the registry, not a previous provider's map.
providers: Layer.fresh(Layer.mergeAll(Cloudflare.providers(), DockerLive)),
});
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
type Scratch = Parameters<Parameters<typeof test.provider>[1]>[0];
/** Deploy a standalone container application from a remote `image`. */
const deployImage = (scratch: Scratch, image: string) =>
scratch.deploy(
Effect.gen(function* () {
return {
app: yield* Cloudflare.Container("DigestReuse", { image }).Application,
};
}),
);
/** The live (active) image reference + version of an application. */
const live = (accountId: string, applicationId: string) =>
Containers.getContainerApplication({ accountId, applicationId }).pipe(
Effect.map((app) => ({
version: app.version,
image: app.configuration.image,
durableObjects: app.durableObjects ?? undefined,
})),
);
/**
* Poll until the application's active image is `image`. Cloudflare reports
* the ACTIVE configuration (and version) until a rollout completes, so any
* assertion on a changed image has to wait for the rollout.
*/
const waitForImage = (
accountId: string,
applicationId: string,
image: string,
) =>
live(accountId, applicationId).pipe(
Effect.repeat({
schedule: Schedule.spaced("3 seconds"),
until: (app) => app.image === image,
times: 30,
}),
);
/** `<repo>:<sourceHash>` — the mutable tag the provider pushed `app` as. */
const taggedRefOf = (app: {
configuration: { image?: string };
hash?: { image: string };
}) => {
const digestRef = app.configuration.image!;
return `${digestRef.slice(0, digestRef.indexOf("@"))}:${app.hash!.image}`;
};
/** Rewrite the persisted attributes of the scratch row for `fqn`. */
const patchRow = <A extends Record<string, any>>(
fqn: string,
patch: (attr: A) => A,
) =>
Effect.gen(function* () {
const state = yield* yield* State;
const stk = yield* Stack;
const key = { stack: stk.name, stage: stk.stage, fqn };
const row = (yield* state.get(key)) as ResourceState;
yield* state.set({
...key,
value: { ...row, attr: patch(row.attr as A) },
});
});
// Every test owns a name-namespaced scratch stack, so they can run
// concurrently. The `exclusive: true` builder tests below still take the
// whole-process lock (they mutate `BUILDX_BUILDER`) and serialize among
// themselves.
describe.concurrent(
"ContainerApplication",
{
tags: [
"provider:cloudflare",
"provider:cloudflare:container",
"provider:docker",
"live",
],
},
() => {
test.provider(
"recovers an interrupted generated create and reconciles observed drift",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const first = yield* stack.deploy(applications());
const state = yield* yield* State;
const key = {
stack: stack.name,
stage: stack.stage,
fqn: "CachedIdentity",
};
const row = yield* state.get(key);
assert(row?.status === "created" || row?.status === "updated");
yield* Containers.updateContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
maxInstances: 4,
});
const observed = yield* Containers.getContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
});
expect(observed.maxInstances).toBe(4);
const interrupted = {
...row,
status: "creating" as const,
attr: undefined,
};
yield* Effect.gen(function* () {
yield* state.set({ ...key, value: interrupted });
const plan = yield* stack.plan(applications());
expect(plan.resources.CachedIdentity).toMatchObject({
action: "create",
state: {
instanceId: row.instanceId,
attr: {
applicationId: first.owned.applicationId,
applicationName: first.owned.applicationName,
accountId: first.owned.accountId,
maxInstances: 4,
},
},
});
expect(plan.resources.OtherIdentity.action).toBe("noop");
expect(yield* state.get(key)).toEqual(interrupted);
const renamed = yield* stack.plan(
applications(
2,
`renamed-${first.owned.applicationName.slice(-24)}`,
),
);
expect(renamed.resources.CachedIdentity.action).toBe("replace");
expect(yield* state.get(key)).toEqual(interrupted);
expect(
yield* Containers.getContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
}),
).toEqual(observed);
yield* state.set({
...key,
value: { ...interrupted, providerMode: "local" },
});
const switched = yield* stack.plan(applications());
expect(switched.resources.CachedIdentity.action).toBe("replace");
expect(
switched.resources.CachedIdentity.state?.attr,
).toBeUndefined();
yield* state.set({
...key,
value: {
...interrupted,
instanceId: "00000000000000000000000000000000",
},
});
const unrelated = yield* stack.plan(applications());
expect(unrelated.resources.CachedIdentity.action).toBe("create");
expect(
unrelated.resources.CachedIdentity.state?.attr,
).toBeUndefined();
yield* state.delete(key);
const fresh = yield* stack.plan(applications());
expect(fresh.resources.CachedIdentity.action).toBe("create");
expect(fresh.resources.CachedIdentity.state).toBeUndefined();
expect(yield* state.get(key)).toBeUndefined();
yield* state.set({ ...key, value: interrupted });
const recovered = yield* stack.deploy(applications(5));
expect(recovered.owned.applicationId).toBe(
first.owned.applicationId,
);
expect(recovered.other.applicationId).toBe(
first.other.applicationId,
);
expect(yield* state.get(key)).toMatchObject({
status: "created",
instanceId: row.instanceId,
attr: {
applicationId: first.owned.applicationId,
maxInstances: 5,
},
});
const live = yield* Containers.getContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
});
expect(live.maxInstances).toBe(5);
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
yield* stack.destroy();
yield* Effect.gen(function* () {
const restored = yield* yield* State;
yield* restored.set({ ...key, value: row });
expect(yield* restored.get(key)).toEqual(row);
}).pipe(Effect.provide(Layer.fresh(stack.state)));
yield* stack.destroy();
}).pipe(Effect.orDie),
),
);
for (const app of [first.owned, first.other]) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(logLevel),
{ timeout: 120_000 },
);
test.provider(
"keeps explicit names unowned even when they match a generated identity",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const first = yield* stack.deploy(applications());
const program = applications(2, first.owned.applicationName);
const state = yield* yield* State;
const key = {
stack: stack.name,
stage: stack.stage,
fqn: "CachedIdentity",
};
const row = yield* state.get(key);
assert(row?.status === "created" || row?.status === "updated");
const observed = yield* Containers.getContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
});
yield* Effect.gen(function* () {
const interrupted = {
...row,
props: { ...row.props, name: first.owned.applicationName },
status: "creating" as const,
attr: undefined,
};
yield* state.set({ ...key, value: interrupted });
const recovery = yield* stack
.plan(program)
.pipe(
Effect.catchTag("OwnedBySomeoneElse", () =>
Effect.succeed("unowned"),
),
);
expect(recovery).toBe("unowned");
expect(yield* state.get(key)).toEqual(interrupted);
yield* state.delete(key);
const discovery = yield* stack
.plan(program)
.pipe(
Effect.catchTag("OwnedBySomeoneElse", () =>
Effect.succeed("unowned"),
),
);
expect(discovery).toBe("unowned");
expect(yield* state.get(key)).toBeUndefined();
expect(
yield* Containers.getContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
}),
).toEqual(observed);
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
yield* stack.destroy();
yield* Effect.gen(function* () {
const restored = yield* yield* State;
yield* restored.set({ ...key, value: row });
expect(yield* restored.get(key)).toEqual(row);
}).pipe(Effect.provide(Layer.fresh(stack.state)));
yield* stack.destroy();
}).pipe(Effect.orDie),
),
);
for (const app of [first.owned, first.other]) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(logLevel),
{ timeout: 120_000 },
);
test.provider(
"destroys an interrupted generated create without resuming it",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const first = yield* stack.deploy(applications());
const state = yield* yield* State;
const key = {
stack: stack.name,
stage: stack.stage,
fqn: "CachedIdentity",
};
const row = yield* state.get(key);
assert(row?.status === "created" || row?.status === "updated");
yield* Effect.gen(function* () {
yield* state.set({
...key,
value: { ...row, status: "creating", attr: undefined },
});
yield* stack.destroy();
for (const app of [first.owned, first.other]) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
expect(yield* state.get(key)).toBeUndefined();
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
const restored = yield* yield* State;
yield* restored.set({ ...key, value: row });
expect(yield* restored.get(key)).toEqual(row);
}).pipe(Effect.provide(Layer.fresh(stack.state)), Effect.orDie),
),
);
yield* stack.destroy();
}).pipe(logLevel),
{ timeout: 120_000 },
);
test.provider(
"compares each legacy application's previous digest independently",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const initial = yield* stack.deploy(historyApplications());
expect(initial.first.hash?.digest).not.toBe(
initial.second.hash?.digest,
);
const state = yield* yield* State;
const ids = ["HistoryFirst", "HistorySecond"];
const updated = yield* Effect.acquireUseRelease(
Effect.forEach(ids, (id) =>
state.get({ stack: stack.name, stage: stack.stage, fqn: id }),
),
() =>
Effect.gen(function* () {
for (const id of ids) {
yield* patchRow<typeof initial.first>(id, (attr) => ({
...attr,
hash: { image: attr.hash!.image },
}));
}
const updated = yield* stack.deploy(historyApplications(true));
assert(updated.target);
expect(updated.target.configuration.image).not.toBe(
initial.first.configuration.image,
);
expect(updated.target.hash?.digest).toBe(
initial.first.hash?.digest,
);
expect(updated.first.applicationId).toBe(
initial.first.applicationId,
);
expect(updated.first.configuration.image).toBe(
initial.first.configuration.image,
);
expect(updated.second.applicationId).toBe(
initial.second.applicationId,
);
expect(updated.second.configuration.image).toBe(
updated.target.configuration.image,
);
expect(updated.second.hash?.digest).toBe(
initial.first.hash?.digest,
);
const desiredImage = updated.target.configuration.image;
const observed = yield* live(
updated.second.accountId,
updated.second.applicationId,
).pipe(
Effect.repeat({
schedule: Schedule.spaced("5 seconds"),
until: (app) => app.image === desiredImage,
times: 8,
}),
);
expect(observed.image).toBe(updated.target.configuration.image);
return updated;
}),
(rows) =>
Effect.forEach(rows, (row, i) => {
assert(row);
return state.set({
stack: stack.name,
stage: stack.stage,
fqn: ids[i],
value: row,
});
}).pipe(Effect.orDie),
);
yield* stack.destroy();
for (const app of [updated.first, updated.second, updated.target]) {
assert(app);
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(logLevel),
{ timeout: 120_000 },
);
for (const failure of ["failed", "interrupted"] as const) {
test.provider(
`retries ${failure === "failed" ? "a failed" : "an interrupted"} publication in the same provider`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const provider = yield* Provider.findProvider(ContainerPlatform);
const program = (includeSecond = false) =>
Effect.gen(function* () {
expect(yield* Provider.findProvider(ContainerPlatform)).toBe(
provider,
);
return yield* recoveryApplications(
failure === "interrupted" ? 10 : 0,
includeSecond,
);
});
if (failure === "failed") {
const failed = yield* Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.BUILDX_BUILDER;
process.env.BUILDX_BUILDER = "alchemy-publication-missing";
return previous;
}),
() => stack.deploy(program()).pipe(Effect.exit),
(previous) =>
Effect.sync(() => {
if (previous === undefined)
delete process.env.BUILDX_BUILDER;
else process.env.BUILDX_BUILDER = previous;
}),
);
assert(Exit.isFailure(failed));
expect(Cause.pretty(failed.cause)).toContain(
"alchemy-publication-missing",
);
} else {
yield* Effect.acquireUseRelease(
stack.deploy(program()).pipe(Effect.forkChild),
(fiber) =>
Effect.gen(function* () {
const history = yield* buildHistory.pipe(
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (builds) =>
builds.some((build) => build.status === "Running"),
times: 10,
}),
);
const running = history.find(
(build) => build.status === "Running",
);
assert(running);
yield* Fiber.interrupt(fiber);
const interrupted = yield* Fiber.await(fiber);
assert(Exit.isFailure(interrupted));
expect(Cause.hasInterrupts(interrupted.cause)).toBe(true);
const stopped = yield* buildHistory.pipe(
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (builds) =>
builds.some(
(build) =>
build.ref === running.ref &&
build.status !== "Running",
),
times: 8,
}),
);
const stoppedBuild = stopped.find(
(build) => build.ref === running.ref,
);
assert(stoppedBuild);
expect(stoppedBuild.status).not.toBe("Running");
}),
(fiber) => Fiber.interrupt(fiber),
);
}
const failedBuilds = yield* buildHistory;
expect(
failedBuilds.filter((build) => build.status === "Completed"),
).toHaveLength(0);
const recovered = yield* stack.deploy(program());
const recoveredBuilds = yield* buildHistory;
expect(
recoveredBuilds.length - failedBuilds.length,
).toBeGreaterThanOrEqual(1);
expect(
recoveredBuilds.length - failedBuilds.length,
).toBeLessThanOrEqual(6);
expect(
recoveredBuilds.filter((build) => build.status === "Completed"),
).toHaveLength(1);
const shared = yield* stack.deploy(program(true));
assert(shared.second);
expect(shared.first.applicationId).toBe(
recovered.first.applicationId,
);
expect(shared.second.applicationId).not.toBe(
shared.first.applicationId,
);
expect(shared.second.configuration.image).toBe(
shared.first.configuration.image,
);
expect(yield* buildHistory).toEqual(recoveredBuilds);
for (const app of [shared.first, shared.second]) {
const observed = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
});
expect(observed.configuration.image).toBe(
shared.first.configuration.image,
);
}
yield* stack.destroy();
for (const app of [shared.first, shared.second]) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(withBuilder(`alchemy-publication-${failure}`), logLevel),
{ timeout: 120_000, exclusive: true },
);
}
test.provider(
"shares publication without mixing contexts or application settings",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({
prefix: "alchemy-publication-",
});
const contexts = {
shared: path.join(root, "shared"),
other: path.join(root, "other"),
changed: path.join(root, "changed"),
};
for (const [kind, context] of Object.entries(contexts)) {
yield* fs.makeDirectory(context);
yield* fs.writeFileString(
path.join(context, ".gitignore"),
"payload.txt\n",
);
yield* fs.writeFileString(path.join(context, "payload.txt"), kind);
yield* fs.writeFileString(path.join(context, "input.txt"), "first");
yield* fs.writeFileString(
path.join(context, "Dockerfile"),
[
"FROM alpine:3.19",
"COPY payload.txt input.txt /",
'CMD ["sleep", "3600"]',
...(kind === "changed" ? ["LABEL variant=changed"] : []),
].join("\n"),
);
}
const program = publicationApplications(contexts);
const first = yield* stack.deploy(program);
const history = yield* buildHistory;
expect(
history.filter((build) => build.status === "Completed"),
).toHaveLength(3);
// Each distinct publication can make at most six export attempts.
expect(history.length).toBeLessThanOrEqual(18);
expect(first.first.hash?.image).not.toBe(first.other.hash?.image);
expect(first.first.configuration.image).toBe(
first.second.configuration.image,
);
expect(first.first.configuration.image).not.toBe(
first.other.configuration.image,
);
expect(first.first.hash?.image).not.toBe(first.changed.hash?.image);
expect(first.first.applicationId).not.toBe(
first.second.applicationId,
);
for (const [slot, app] of [
["first", first.first],
["second", first.second],
] as const) {
const observed = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
});
expect(observed.configuration.image).toBe(
first.first.configuration.image,
);
expect(observed.configuration.environmentVariables).toContainEqual({
name: "SLOT",
value: slot,
});
expect(observed.maxInstances).toBe(slot === "first" ? 2 : 3);
}
const unchanged = yield* stack.deploy(program);
expect(unchanged.first.configuration.image).toBe(
first.first.configuration.image,
);
expect(yield* buildHistory).toEqual(history);
yield* fs.writeFileString(
path.join(contexts.shared, "input.txt"),
"second",
);
const updated = yield* stack.deploy(program);
const updateHistory = (yield* buildHistory).filter(
(build) => !history.some((previous) => previous.ref === build.ref),
);
expect(
updateHistory.filter((build) => build.status === "Completed"),
).toHaveLength(1);
expect(updateHistory.length).toBeLessThanOrEqual(6);
expect(updated.first.applicationId).toBe(first.first.applicationId);
expect(updated.second.applicationId).toBe(first.second.applicationId);
expect(updated.first.configuration.image).toBe(
updated.second.configuration.image,
);
expect(updated.first.hash?.digest).not.toBe(first.first.hash?.digest);
expect(updated.other.configuration.image).toBe(
first.other.configuration.image,
);
expect(updated.changed.configuration.image).toBe(
first.changed.configuration.image,
);
yield* stack.destroy();
for (const app of Object.values(updated)) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(withBuilder("alchemy-publication-cache"), logLevel),
{ timeout: 120_000, exclusive: true },
);
registryCacheTest.provider(
"caches default publications and imports repository layers in a fresh builder",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const docker = yield* Docker;
const context = yield* fs.makeTempDirectoryScoped({
prefix: "alchemy-default-cache-",
});
yield* fs.writeFileString(
path.join(context, "Dockerfile"),
'FROM alpine:3.19\nRUN cat /proc/sys/kernel/random/uuid > /layer\nCOPY payload /payload\nCMD ["sleep", "3600"]\n',
);
yield* fs.writeFileString(path.join(context, "payload"), "first");
const program = sharedApplication(context);
const first = yield* stack.deploy(program);
expect(first.app.configuration.image).toContain(
`/${first.app.applicationName}@sha256:`,
);
const firstHistory = yield* buildHistory;
expect(
firstHistory.filter((build) => build.status === "Completed"),
).toHaveLength(1);
const credentials =
yield* Containers.createContainerRegistryCredentials({
accountId: first.app.accountId,
registryId: "registry.cloudflare.com",
permissions: ["pull"],
expirationMinutes: 15,
});
const username = credentials.username ?? credentials.user;
assert(username);
const client = yield* HttpClient.HttpClient;
const cacheDigest = Effect.gen(function* () {
const response = yield* client.execute(
HttpClientRequest.head(
`https://registry.cloudflare.com/v2/${first.app.accountId}/${first.app.applicationName}/manifests/buildcache`,
).pipe(
HttpClientRequest.basicAuth(username, credentials.password),
HttpClientRequest.setHeader(
"Accept",
"application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
),
),
);
expect(response.status).toBe(200);
return response.headers["docker-content-digest"];
});
expect(yield* cacheDigest).toBe(first.app.hash?.digest);
const layersOf = Effect.fn(function* (digest: string) {
const response = yield* client.execute(
HttpClientRequest.get(
`https://registry.cloudflare.com/v2/${first.app.accountId}/${first.app.applicationName}/manifests/${digest}`,
).pipe(
HttpClientRequest.basicAuth(username, credentials.password),
HttpClientRequest.setHeader(
"Accept",
"application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
),
),
);
expect(response.status).toBe(200);
const manifest = yield* response.json.pipe(
Effect.flatMap(
Schema.decodeUnknownEffect(
Schema.Struct({
layers: Schema.Array(
Schema.Struct({ digest: Schema.String }),
),
}),
),
),
);
return manifest.layers.map((layer) => layer.digest);
});
const firstLayers = yield* layersOf(first.app.hash!.digest!);
expect(firstLayers.length).toBeGreaterThanOrEqual(2);
yield* fs.writeFileString(path.join(context, "payload"), "changed");
const changed = yield* Effect.gen(function* () {
// A new BuildKit instance cannot reuse the first builder's local layers.
expect(yield* buildHistory).toHaveLength(0);
const changed = yield* stack.deploy(program);
const completed = (yield* buildHistory).filter(
(build) => build.status === "Completed",
);
expect(completed).toHaveLength(1);
const log = yield* docker.run([
"buildx",
"history",
"logs",
completed[0]!.ref.split("/").at(-1)!,
"--progress",
"plain",
]);
const text = `${log.stdout}\n${log.stderr}`;
expect(text).toContain(
`importing cache manifest from registry.cloudflare.com/${first.app.accountId}/${first.app.applicationName}:buildcache`,
);
const changedLayers = yield* layersOf(changed.app.hash!.digest!);
// Re-executing RUN would create a different UUID and therefore a different layer.
expect(changedLayers.slice(0, -1)).toEqual(
firstLayers.slice(0, -1),
);
expect(changedLayers.at(-1)).not.toBe(firstLayers.at(-1));
return changed;
}).pipe(
withBuilder("alchemy-default-cache-changed", {
attestations: false,
}),
);
expect(changed.app.applicationId).toBe(first.app.applicationId);
expect(changed.app.applicationName).toBe(first.app.applicationName);
expect(changed.app.configuration.image).not.toBe(
first.app.configuration.image,
);
// The registry keeps serving an overwritten tag's previous target
// for a while (observed ~25s), so wait for the new one.
expect(
yield* cacheDigest.pipe(
Effect.repeat({
schedule: Schedule.spaced("4 seconds"),
until: (digest) => digest === changed.app.hash?.digest,
times: 10,
}),
),
).toBe(changed.app.hash?.digest);
yield* fs.writeFileString(path.join(context, "payload"), "first");
const restored = yield* Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.BUILDX_BUILDER;
process.env.BUILDX_BUILDER =
"alchemy-default-cache-must-not-build";
return previous;
}),
() => stack.deploy(program),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.BUILDX_BUILDER;
else process.env.BUILDX_BUILDER = previous;
}),
);
expect(restored.app.applicationId).toBe(first.app.applicationId);
expect(restored.app.configuration.image).toBe(
first.app.configuration.image,
);
expect(yield* buildHistory).toEqual(firstHistory);
// Reusing a finished image leaves the latest exported layer cache in place.
expect(yield* cacheDigest).toBe(changed.app.hash?.digest);
expect(
(yield* waitForImage(
restored.app.accountId,
restored.app.applicationId,
restored.app.configuration.image!,
)).image,
).toBe(first.app.configuration.image);
yield* stack.destroy();
const gone = yield* Containers.getContainerApplication({
accountId: first.app.accountId,
applicationId: first.app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
until: (app) => app === undefined,
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
expect(gone).toBeUndefined();
}).pipe(
withBuilder("alchemy-default-cache", { attestations: false }),
logLevel,
),
{ timeout: 120_000, exclusive: true },
);
// Publication reuse was confined to a provider instance in:
// https://github.com/alchemy-run/alchemy/commit/275361dfe8358684439e42120298723305175d39
registryCacheTest.provider(
"reuses registry images across fresh providers and invalidates changed build inputs",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({
prefix: "alchemy-registry-cache-",
});
const context = path.join(root, "context");
const repositoryName = `alchemy-registry-cache-${stack.stage.toLowerCase()}`;
yield* fs.makeDirectory(context);
yield* fs.writeFileString(
path.join(root, "bun.lock"),
"outside-first",
);
yield* fs.writeFileString(
path.join(context, ".gitignore"),
"payload\n",
);
yield* fs.writeFileString(
path.join(context, ".dockerignore"),
"ignored\n",
);
yield* fs.writeFileString(
path.join(context, "ignored"),
"ignored-first",
);
// Fresh payload bytes force a cold source tag even after a prior run.
yield* fs.writeFileString(path.join(context, "payload"), root);
yield* fs.writeFileString(
path.join(context, "Dockerfile"),
'FROM alpine:3.19\nCOPY payload /payload\nCMD ["sleep", "3600"]\n',
);
const deploy = (id: string, repository = repositoryName) =>
stack.deploy(
Effect.gen(function* () {
return {
app: yield* Cloudflare.Container(id, {
context,
publish: { repository },
}).Application,
};
}),
);
const first = yield* deploy("First");
const history = yield* buildHistory;
expect(
history.filter((build) => build.status === "Completed"),
).toHaveLength(1);
expect(first.app.configuration.image).toContain(
`/${repositoryName}@sha256:`,
);
const credentials =
yield* Containers.createContainerRegistryCredentials({
accountId: first.app.accountId,
registryId: "registry.cloudflare.com",
permissions: ["pull"],
expirationMinutes: 15,
});
const username = credentials.username ?? credentials.user;
assert(username);
const http = yield* HttpClient.HttpClient;
// The registry keeps serving an overwritten tag's previous target
// for a while (observed ~25s), so wait for the new one.
const cached = yield* http
.execute(
HttpClientRequest.head(
`https://registry.cloudflare.com/v2/${first.app.accountId}/${repositoryName}/manifests/buildcache`,
).pipe(
HttpClientRequest.basicAuth(username, credentials.password),
HttpClientRequest.setHeader(
"Accept",
"application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
),
),
)
.pipe(
Effect.repeat({
schedule: Schedule.spaced("4 seconds"),
until: (response) =>
response.headers["docker-content-digest"] ===
first.app.hash?.digest,
times: 10,
}),
);
expect(cached.status).toBe(200);
expect(cached.headers["docker-content-digest"]).toBe(
first.app.hash?.digest,
);
yield* stack.destroy();
yield* fs.writeFileString(
path.join(root, "bun.lock"),
"outside-second",
);
yield* fs.writeFileString(
path.join(context, "ignored"),
"ignored-second",
);
const second = yield* deploy("Second");
expect(yield* buildHistory).toEqual(history);
expect(second.app.applicationId).not.toBe(first.app.applicationId);
expect(second.app.configuration.image).toBe(
first.app.configuration.image,
);
// Git ignores this file, but Docker copies it into the image.
yield* fs.writeFileString(
path.join(context, "payload"),
`${root}-changed`,
);
const changed = yield* deploy("Second");
expect(changed.app.hash?.digest).not.toBe(second.app.hash?.digest);
expect(
(yield* buildHistory).filter(
(build) => build.status === "Completed",
),
).toHaveLength(2);
const moved = yield* deploy("Second", `${repositoryName}-other`);
expect(moved.app.configuration.image).toContain(
`/${repositoryName}-other@sha256:`,
);
expect(
(yield* buildHistory).filter(
(build) => build.status === "Completed",
),
).toHaveLength(3);
yield* stack.destroy();
const deleted = yield* Containers.getContainerApplication({
accountId: moved.app.accountId,
applicationId: moved.app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}).pipe(withBuilder("alchemy-registry-cache"), logLevel),
{ timeout: 180_000, exclusive: true },
);
test.provider(
"reuses shared registry images across stages without a builder and moves repositories with an unchanged digest",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({
prefix: "alchemy-shared-publication-",
});
const context = path.join(root, "first");
const otherContext = path.join(root, "second");
for (const dir of [context, otherContext]) {
yield* fs.makeDirectory(dir);
yield* fs.writeFileString(path.join(dir, "payload.txt"), "first");
yield* fs.writeFileString(
path.join(dir, "Dockerfile"),
'FROM alpine:3.19\nCOPY payload.txt /payload.txt\nCMD ["sleep", "3600"]\n',
);
}
const initial = yield* stack.deploy(sharedApplication(context));
const repositoryName = initial.app.applicationName;
const shared = yield* stack.deploy(
sharedApplication(context, repositoryName),
);
expect(shared.app.applicationId).toBe(initial.app.applicationId);
const history = yield* buildHistory;
expect(
history.filter((build) => build.status === "Completed"),
).toHaveLength(2);
const secondStage = {
stage: `${stack.stage}-shared`,
stack: Stack(
stack.name,
{
providers: Layer.fresh(Cloudflare.providers()),
state: stack.state,
},
sharedApplication(otherContext, repositoryName),
),
};
yield* Destroy.destroy(secondStage);
const reused = yield* Effect.gen(function* () {
// A fresh provider and an unusable builder rule out process-local reuse.
const reused = yield* Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.BUILDX_BUILDER;
process.env.BUILDX_BUILDER =
"alchemy-shared-cache-must-not-build";
return previous;
}),
() => Deploy.deploy(secondStage),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.BUILDX_BUILDER;
else process.env.BUILDX_BUILDER = previous;
}),
);
expect(reused.app.applicationId).not.toBe(shared.app.applicationId);
expect(reused.app.hash?.image).toBe(shared.app.hash?.image);
expect(reused.app.configuration.image).toBe(
shared.app.configuration.image,
);
expect(yield* buildHistory).toEqual(history);
expect(
(yield* live(reused.app.accountId, reused.app.applicationId))
.image,
).toBe(shared.app.configuration.image);
const moved = yield* stack.deploy(
sharedApplication(context, `${repositoryName}-moved`),
);
expect(moved.app.applicationId).toBe(shared.app.applicationId);
expect(moved.app.hash?.digest).toBe(shared.app.hash?.digest);
expect(moved.app.configuration.image).toContain(
`/${repositoryName}-moved@sha256:`,
);
expect(
(yield* waitForImage(
moved.app.accountId,
moved.app.applicationId,
moved.app.configuration.image!,
)).image,
).toBe(moved.app.configuration.image);
expect(
(yield* live(reused.app.accountId, reused.app.applicationId))
.image,
).toBe(shared.app.configuration.image);
return reused;
}).pipe(
Effect.ensuring(Destroy.destroy(secondStage).pipe(Effect.orDie)),
);
yield* stack.destroy();
for (const app of [shared.app, reused.app]) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(
// Timestamped provenance would give identical image builds different digests.
withBuilder("alchemy-shared-registry-cache", { attestations: false }),
logLevel,
),
{ timeout: 120_000, exclusive: true },
);
for (const fixture of [
{
name: "Dockerfile",
program: Effect.gen(function* () {
const worker = yield* ExternalContainerWorker;
const app: Cloudflare.ContainerApplication =
yield* ExternalContainer.Application;
return { app, url: worker.url.as<string>() };
}),
route: "/hello",
response: "hello from external container",
},
{
name: "generated",
program: Effect.gen(function* () {
const worker = yield* EffectfulContainerWorker;
const app: Cloudflare.ContainerApplication =
yield* MyContainer.Application;
return { app, url: worker.url.as<string>() };
}).pipe(Effect.provide(MyContainerLive)),
route: "/ping",
response: "pong",
},
]) {
test.provider(
`exports ${fixture.name} builds from a non-loading builder to the registry`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const docker = yield* Docker;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDir = yield* Config.String("DOCKER_CONFIG").pipe(
Config.orElse(() =>
Config.String("HOME").pipe(
Config.map((home) => path.join(home, ".docker")),
),
),
);
const configFile = path.join(configDir, "config.json");
const readConfig = fs
.readFileString(configFile)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.succeed(undefined),
),
);
const configBefore = yield* readConfig;
const builder = `alchemy-registry-export-${fixture.name.toLowerCase()}`;
const selected = yield* docker.run(["buildx", "inspect"]);
expect(selected.stdout).toMatch(/Driver:\s+docker-container/);
expect(selected.stdout).toContain(builder);
const deployed = yield* stack.deploy(fixture.program);
const tag = taggedRefOf(deployed.app);
const local = yield* docker.image
.inspect(tag)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.succeed(undefined),
),
);
// Buildx 0.26+ exports from BuildKit straight to the registry, so
// the image never enters the local store. Older plugins `--load`
// it and `docker push` from there.
const exported = yield* supportsRegistryExport;
if (exported) {
expect(local).toBeUndefined();
} else {
expect(local).toBeDefined();
}
expect(deployed.app.hash?.digest).toMatch(/^sha256:[a-f0-9]{64}$/);
const { accountId, applicationId } = deployed.app;
const credentials =
yield* Containers.createContainerRegistryCredentials({
accountId,
registryId: "registry.cloudflare.com",
permissions: ["pull"],
expirationMinutes: 15,
});
const username = credentials.username ?? credentials.user;
assert(username);
const client = yield* HttpClient.HttpClient;
const repository = tag.slice(
"registry.cloudflare.com/".length,
tag.lastIndexOf(":"),
);
const manifest = yield* client.execute(
HttpClientRequest.get(
`https://registry.cloudflare.com/v2/${repository}/manifests/${deployed.app.hash!.digest}`,
).pipe(
HttpClientRequest.basicAuth(username, credentials.password),
HttpClientRequest.setHeader(
"Accept",
"application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
),
),
);
expect(manifest.status).toBe(200);
expect(manifest.headers["docker-content-digest"]).toBe(
deployed.app.hash!.digest,
);
const cache = yield* client.execute(
HttpClientRequest.head(
`https://registry.cloudflare.com/v2/${repository}/manifests/buildcache`,
).pipe(
HttpClientRequest.basicAuth(username, credentials.password),
HttpClientRequest.setHeader(
"Accept",
"application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
),
),
);
expect(cache.status).toBe(200);
expect(cache.headers["docker-content-digest"]).toBe(
deployed.app.hash!.digest,
);
if (exported) {
// BuildKit's registry exporter publishes an OCI index (with
// attestations) carrying the requested platform.
const index = yield* manifest.json.pipe(
Effect.flatMap(
Schema.decodeUnknownEffect(
Schema.Struct({
mediaType: Schema.String,
manifests: Schema.Array(
Schema.Struct({
platform: Schema.Struct({
os: Schema.String,
architecture: Schema.String,
}),
}),
),
}),
),
),
);
expect(index.mediaType).toBe(
"application/vnd.oci.image.index.v1+json",
);
expect(
index.manifests.some(
({ platform }) =>
platform.os === "linux" &&
platform.architecture === "amd64",
),
).toBe(true);
} else {
// `docker push --platform` ships the single platform variant.
const single = yield* manifest.json.pipe(
Effect.flatMap(
Schema.decodeUnknownEffect(
Schema.Struct({ mediaType: Schema.String }),
),
),
);
expect([
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
]).toContain(single.mediaType);
}
const observed = yield* Containers.getContainerApplication({
accountId,
applicationId,
});
expect(observed.configuration.image).toBe(
deployed.app.configuration.image,
);
const response = yield* Effect.gen(function* () {
const response = yield* client.get(
`${deployed.url}${fixture.route}`,
);
return { status: response.status, body: yield* response.text };
}).pipe(
Effect.timeout("45 seconds"),
Effect.repeat({
schedule: Schedule.spaced("3 seconds"),
until: (response) =>
response.status === 200 &&
response.body.includes(fixture.response),
times: 8,
}),
);
expect(response.status).toBe(200);
expect(response.body).toContain(fixture.response);
expect(yield* readConfig).toBe(configBefore);
const unchanged = yield* stack.deploy(fixture.program);
expect(unchanged.app.applicationId).toBe(applicationId);
expect(unchanged.app.hash).toEqual(deployed.app.hash);
yield* stack.destroy();
const deleted = yield* Containers.getContainerApplication({
accountId,
applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}).pipe(
withBuilder(
`alchemy-registry-export-${fixture.name.toLowerCase()}`,
),
logLevel,
),
{
tags: ["provider:cloudflare:r2", "provider:cloudflare:worker"],
timeout: 120_000,
exclusive: true,
},
);
}
for (const maxInstances of [2, 4]) {
for (const field of [
"applicationId",
"applicationName",
"accountId",
] as const) {
test.provider(
`plans replacement for cached ${field} mismatches with ${maxInstances === 2 ? "unchanged" : "changed"} props`,
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const first = yield* stack.deploy(applications());
const { accountId } = first.owned;
const observed = yield* Effect.forEach(
[first.owned, first.other],
(app) =>
Containers.getContainerApplication({
accountId,
applicationId: app.applicationId,
}),
);
const state = yield* yield* State;
const key = {
stack: stack.name,
stage: stack.stage,
fqn: "CachedIdentity",
};
const row = yield* state.get(key);
assert(row?.status === "created" || row?.status === "updated");
yield* Effect.gen(function* () {
const inconsistent = {
...row,
attr: {
...row.attr,
[field]:
field === "accountId"
? "00000000000000000000000000000000"
: first.other[field],
},
};
yield* state.set({ ...key, value: inconsistent });
const plan = yield* stack.plan(applications(maxInstances));
expect(plan.resources.CachedIdentity.action).toBe("replace");
expect(plan.resources.CachedIdentity.state).toEqual(
inconsistent,
);
expect(plan.resources.OtherIdentity.action).toBe("noop");
expect(yield* state.get(key)).toEqual(inconsistent);
for (const before of observed) {
const after = yield* Containers.getContainerApplication({
accountId,
applicationId: before.id,
});
expect(after).toMatchObject({
id: before.id,
name: before.name,
version: before.version,
maxInstances: before.maxInstances,
configuration: before.configuration,
});
}
}).pipe(
// Restore the real identity before teardown, even on failure.
Effect.ensuring(
state.set({ ...key, value: row }).pipe(Effect.orDie),
),
);
const checked = yield* Drift.detect(stack);
expect(checked.resources.CachedIdentity.attr).toMatchObject({
applicationId: first.owned.applicationId,
applicationName: first.owned.applicationName,
accountId,
});
const recovered = yield* stack.deploy(applications());
expect(recovered.owned.applicationId).toBe(
first.owned.applicationId,
);
expect(recovered.other.applicationId).toBe(
first.other.applicationId,
);
yield* stack.destroy();
for (const app of [first.owned, first.other]) {
const deleted = yield* Containers.getContainerApplication({
accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(logLevel),
{ timeout: 120_000 },
);
}
}
test.provider(
"replaces the fixture when its configured name changes",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const first = yield* stack.deploy(applications());
const name = `renamed-${first.owned.applicationName.slice(-24)}`;
const renamed = applications(2, name);
const scaling = yield* stack.plan(applications(4));
expect(scaling.resources.CachedIdentity.action).toBe("update");
const plan = yield* stack.plan(renamed);
expect(plan.resources.CachedIdentity.action).toBe("replace");
expect(plan.resources.OtherIdentity.action).toBe("noop");
const before = yield* Containers.getContainerApplication({
accountId: first.owned.accountId,
applicationId: first.owned.applicationId,
});
expect(before.name).toBe(first.owned.applicationName);
expect(before.maxInstances).toBe(2);
const second = yield* stack.deploy(renamed);
expect(second.owned.applicationName).toBe(name);
expect(second.owned.applicationId).not.toBe(
first.owned.applicationId,
);
expect(second.other.applicationId).toBe(first.other.applicationId);
const observed = yield* Containers.getContainerApplication({
accountId: second.owned.accountId,
applicationId: second.owned.applicationId,
});
expect(observed.name).toBe(name);
yield* stack.destroy();
for (const app of [first.owned, second.owned, second.other]) {
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}
}).pipe(logLevel),
{ timeout: 120_000 },
);
test.provider(
"forwards the fixture's memoryMib to Cloudflare",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const app = yield* stack.deploy(RemoteContainer.Application);
expect(app.configuration.memoryMib).toBe(4096);
const live = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
});
expect(live.configuration.memoryMib).toBe(4096);
expect(live.configuration.instanceType).not.toBe("lite");
yield* stack.destroy();
const deleted = yield* Containers.getContainerApplication({
accountId: app.accountId,
applicationId: app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
until: (app) => app === undefined,
times: 8,
}),
);
expect(deleted).toBeUndefined();
}).pipe(logLevel),
{ tags: ["provider:cloudflare:r2"], timeout: 120_000 },
);
// Canonical `list()` test (Cloudflare account collection, pattern (b)).
// `listContainerApplications` returns the full application objects in one
// (non-paginated) response, so `list()` maps each into the exact `read`
// Attributes shape. Deploying a real container application requires a Docker
// build + push to the Cloudflare registry (not feasible in this harness), so
// this is a read-only enumeration assertion: the result is a well-typed array
// (possibly empty on an account with no container applications) and every
// element carries the full Attributes shape.
test.provider("list enumerates container applications", (stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const provider = yield* Provider.findProvider(Cloudflare.Container);
const all = yield* provider.list();
expect(Array.isArray(all)).toBe(true);
for (const app of all) {
expect(typeof app.applicationId).toBe("string");
expect(typeof app.applicationName).toBe("string");
expect(typeof app.accountId).toBe("string");
expect(app.configuration).toBeDefined();
}
yield* stack.destroy();
}).pipe(logLevel),
);
// Issue #953 (2): an `image` that already references the target registry
// (e.g. pushed by CI) is deployed as-is — no docker pull/tag/push
// round-trip. The first deploy pushes a public image into the account
// registry the normal way; the second deploy references it directly, both
// by digest and by tag. The old (remote) path would have re-tagged it into
// a repository named after the consumer app, so `configuration.image`
// landing on the source's digest reference proves the as-is path ran.
//
// The tag consumer is the only place `resolveRegistryDigest` (the
// `HEAD /v2/<repo>/manifests/<tag>` probe against Cloudflare's registry)
// runs on the happy path — a digest reference short-circuits it and a
// local push reads the digest from `docker push` output.
test.provider(
"pre-pushed registry image is deployed as-is",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const source = yield* scratch.deploy(
Effect.gen(function* () {
return {
app: yield* Cloudflare.Container("PrepushSource", {
image: "mendhak/http-https-echo:latest",
}).Application,
};
}),
);
const pushedRef = source.app.configuration.image!;
expect(pushedRef).toMatch(
/^registry\.cloudflare\.com\/.*@sha256:[a-f0-9]{64}$/,
);
// The mutable tag the provider pushed: `<repo>:<sourceHash>`.
const taggedRef = `${pushedRef.slice(0, pushedRef.indexOf("@"))}:${source.app.hash!.image}`;
const all = yield* scratch.deploy(
Effect.gen(function* () {
return {
app: yield* Cloudflare.Container("PrepushSource", {
image: "mendhak/http-https-echo:latest",
}).Application,
byDigest: yield* Cloudflare.Container("PrepushByDigest", {
image: pushedRef,
}).Application,
byTag: yield* Cloudflare.Container("PrepushByTag", {
image: taggedRef,
}).Application,
};
}),
);
expect(all.byDigest.configuration.image).toBe(pushedRef);
expect(all.byTag.configuration.image).toBe(pushedRef);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 600_000 },
);
// #1282: the image tag is `<repo>:<sourceHash>`, so any change to the
// build inputs pushes a new tag — even when the resulting image is
// byte-for-byte identical (a context file the Dockerfile never COPYs, a
// lockfile, or here: the same remote image spelled with an explicit
// registry host). The provider used to feed that new tag straight into
// `updateContainerApplication`, minting a new application version and
// rolling every instance for nothing. It now resolves the pushed manifest
// digest and, when it matches the live image, skips the update entirely.
test.provider(
"re-pushing an identical image does not create a new application version",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const { accountId } = yield* yield* CloudflareEnvironment;
const first = yield* deployImage(
scratch,
"mendhak/http-https-echo:41",
);
// Same image, different reference: the source hash changes and the
// image is pulled + pushed again, but the registry digest does not.
const second = yield* deployImage(
scratch,
"docker.io/mendhak/http-https-echo:41",
);
expect(second.app.applicationId).toBe(first.app.applicationId);
expect(second.app.configuration.image).toBe(
first.app.configuration.image,
);
expect(second.app.version).toBe(first.app.version);
expect(yield* live(accountId, first.app.applicationId)).toMatchObject(
{
version: first.app.version,
image: first.app.configuration.image,
},
);
// The live reference is the immutable digest, not the mutable tag.
expect(first.app.configuration.image).toMatch(
/^registry\.cloudflare\.com\/.*@sha256:[a-f0-9]{64}$/,
);
// A genuinely different image still updates and rolls out. The API
// reports the ACTIVE configuration (and version) until the rollout
// completes, so poll until the new digest is live.
const third = yield* deployImage(
scratch,
"mendhak/http-https-echo:40",
);
expect(third.app.applicationId).toBe(first.app.applicationId);
expect(third.app.configuration.image).toMatch(
/^registry\.cloudflare\.com\/.*@sha256:[a-f0-9]{64}$/,
);
expect(third.app.configuration.image).not.toBe(
first.app.configuration.image,
);
const rolledOut = yield* waitForImage(
accountId,
first.app.applicationId,
third.app.configuration.image!,
);
expect(rolledOut.image).toBe(third.app.configuration.image);
expect(rolledOut.version).toBeGreaterThan(first.app.version);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 900_000 },
);
test.provider(
"Container.ref reads a container application deployed by another stack",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const owner = yield* scratch.deploy(
Effect.gen(function* () {
return {
app: yield* Cloudflare.Container("RefTarget", {
image: "mendhak/http-https-echo:41",
}).Application,
};
}),
);
// A second stack reaches the application only through `ref`.
const consumer = {
stage: scratch.stage,
stack: Stack(
`${scratch.name}-consumer`,
{
providers: Layer.fresh(Cloudflare.providers()),
state: scratch.state,
},
Effect.gen(function* () {
const app = yield* Cloudflare.Container.ref("RefTarget", {
stack: scratch.name,
});
return {
applicationId: app.applicationId,
applicationName: app.applicationName,
image: app.configuration.image,
};
}),
),
};
const referenced = yield* Deploy.deploy(consumer).pipe(
Effect.ensuring(Destroy.destroy(consumer).pipe(Effect.orDie)),
);
expect(referenced).toEqual({
applicationId: owner.app.applicationId,
applicationName: owner.app.applicationName,
image: owner.app.configuration.image,
});
// Destroying the referencing stack leaves the application running.
const { accountId } = yield* yield* CloudflareEnvironment;
expect((yield* live(accountId, owner.app.applicationId)).image).toBe(
owner.app.configuration.image,
);
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 900_000 },
);
// State written before #1282 carries only `hash.image`, and its live
// application runs the mutable `<repo>:<sourceHash>` tag. On the first
// reconcile after upgrading, the provider resolves that tag's digest
// through the registry, keeps the live tag reference when the rebuilt
// image matches, and persists the digest + configuration fingerprint
// through one normal update. Every later source-hash drift is then a noop.
test.provider(
"legacy state without a digest migrates through one update",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const { accountId } = yield* yield* CloudflareEnvironment;
const first = yield* deployImage(
scratch,
"mendhak/http-https-echo:41",
);
const { applicationId } = first.app;
const taggedRef = taggedRefOf(first.app);
// Put the cloud where a pre-digest engine left it: the mutable tag
// is the ACTIVE image (update + rollout, then wait for it to land).
const legacyConfiguration = {
...first.app.configuration,
image: taggedRef,
};
yield* Containers.updateContainerApplication({
accountId,
applicationId,
configuration: legacyConfiguration,
});
yield* Containers.createContainerApplicationRollout({
accountId,
applicationId,
description: "legacy tag reference",
strategy: "rolling",
kind: "full_auto",
stepPercentage: 100,
targetConfiguration: legacyConfiguration,
});
const legacy = yield* waitForImage(
accountId,
applicationId,
taggedRef,
);
// And the state row: tag reference, source hash only.
yield* patchRow<typeof first.app>("DigestReuse", (attr) => ({
...attr,
configuration: { ...attr.configuration, image: taggedRef },
hash: { image: attr.hash!.image },
}));
// Source-hash drift on the migrated row: the rebuilt digest is
// compared against the live tag's digest (resolved via the
// registry), the live reference is kept, and the digest +
// fingerprint are persisted through one update. That update still
// carries one rollout — the live configuration is Cloudflare-
// enriched, so the pre-fingerprint `deepEqual` misses — which is
// exactly what the fingerprint prevents from here on.
const migrated = yield* deployImage(
scratch,
"docker.io/mendhak/http-https-echo:41",
);
expect(migrated.app.applicationId).toBe(applicationId);
expect(migrated.app.configuration.image).toBe(taggedRef);
expect(migrated.app.hash?.digest).toBe(first.app.hash?.digest);
expect(migrated.app.hash?.configuration).toBeDefined();
const after = yield* live(accountId, applicationId).pipe(
Effect.repeat({
schedule: Schedule.spaced("3 seconds"),
until: (app) => app.version > legacy.version,
times: 30,
}),
);
expect(after.image).toBe(taggedRef);
// From here on, drift is free: no update, no rollout.
const settled = yield* deployImage(
scratch,
"mendhak/http-https-echo:41",
);
expect(settled.app.configuration.image).toBe(taggedRef);
expect(settled.app.hash?.image).not.toBe(migrated.app.hash?.image);
yield* Effect.sleep("10 seconds");
expect(yield* live(accountId, applicationId)).toMatchObject({
image: taggedRef,
version: after.version,
});
yield* scratch.destroy();
}).pipe(logLevel),
{ timeout: 900_000 },
);
// The Durable Object attachment is immutable, so a reconcile that finds an
// application without it (precreate's stub, or a deploy that died between
// precreate and reconcile) deletes and re-creates the application. When
// the source hash has drifted in between, that path rebuilds the image and
// must apply the same digest comparison as a plain update.
test.provider(
"re-creating an application to attach its Durable Object reuses an unchanged digest",
(scratch) =>
Effect.gen(function* () {
yield* scratch.destroy();
const { accountId } = yield* yield* CloudflareEnvironment;
const program = Effect.gen(function* () {
yield* EnvBucket;
const worker = yield* RemoteContainerWorker;
const app = yield* RemoteContainer.Application;
return { url: worker.url.as<string>(), app };
});
const first = yield* scratch.deploy(program);
const namespaceId = first.app.durableObjects?.namespaceId;
expect(namespaceId).toBeDefined();
const digestRef = first.app.configuration.image!;
// Out-of-band, leave the cloud the way precreate does: same name and
// configuration, no Durable Object attachment.
yield* Containers.deleteContainerApplication({
accountId,
applicationId: first.app.applicationId,
});
const deleted = yield* Effect.gen(function* () {
const app = yield* Containers.getContainerApplication({
accountId,
applicationId: first.app.applicationId,
}).pipe(
Effect.catchTag("ContainerApplicationNotFound", () =>
Effect.succeed(undefined),
),
);
const apps = yield* Containers.listContainerApplications({
accountId,
});
const absentFromList = apps.every(
(app) => app.id !== first.app.applicationId,
);
if (app !== undefined && absentFromList) {
yield* Effect.logInfo(
"Deleted container is absent from list but still readable by ID",
{ applicationId: first.app.applicationId },
);
}
return app === undefined && absentFromList;
}).pipe(
Effect.repeat({
schedule: Schedule.spaced("2 seconds"),
until: (deleted) => deleted,
times: 8,
}),
);
expect(deleted).toBe(true);
const detached = yield* Containers.createContainerApplication({
accountId,
name: first.app.applicationName,
maxInstances: first.app.maxInstances,
instances: first.app.instances,
schedulingPolicy: first.app.schedulingPolicy,
constraints: first.app.constraints,
affinities: first.app.affinities,
configuration: first.app.configuration,
});
expect(detached.id).not.toBe(first.app.applicationId);
expect(detached.durableObjects ?? undefined).toBeUndefined();
const observed = yield* Containers.getContainerApplication({
accountId,
applicationId: detached.id,
}).pipe(
Effect.retry({
while: (error) => error._tag === "ContainerApplicationNotFound",
schedule: Schedule.spaced("1 second"),
times: 8,
}),
);
expect(observed.name).toBe(first.app.applicationName);
expect(observed.durableObjects ?? undefined).toBeUndefined();
// Stale the persisted source hash so the re-create must rebuild.
yield* patchRow<typeof first.app>("RemoteContainer", (attr) => ({
...attr,
hash: { ...attr.hash!, image: "0000000000000000" },
}));
const second = yield* scratch.deploy(program);
yield* Effect.logInfo("Container reattachment identities", {
original: first.app.applicationId,
detached: detached.id,
recovered: second.app.applicationId,
});
expect(second.app.applicationId).not.toBe(first.app.applicationId);
expect(second.app.applicationId).not.toBe(detached.id);
expect(second.app.durableObjects?.namespaceId).toBe(namespaceId);
expect(second.app.configuration.image).toBe(digestRef);
expect(second.app.hash?.digest).toBe(first.app.hash?.digest);
expect(second.app.hash?.image).not.toBe("0000000000000000");
expect(
yield* live(accountId, second.app.applicationId).pipe(
Effect.retry({
while: (error) => error._tag === "ContainerApplicationNotFound",
schedule: Schedule.spaced("1 second"),
times: 8,
}),
),
).toMatchObject({
image: digestRef,
durableObjects: { namespaceId },
});
yield* scratch.destroy();
}).pipe(logLevel),
{
tags: ["provider:cloudflare:r2", "provider:cloudflare:worker"],
timeout: 900_000,
},
);
},
);