1import * as Docker from "@/Docker";
2import * as Provider from "@/Provider";
3import { inMemoryState } from "@/State";
4import * as Test from "@/Test/Alchemy";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import { findAvailablePort } from "./Runtime.ts";
11
12const { test } = Test.make({
13 providers: Docker.providers(),
14 state: inMemoryState(),
15});
16
17test.provider(
18 "diff pulls again unless alwaysPull is disabled",
19 () =>
20 Effect.gen(function* () {
21 const provider = yield* Provider.findProvider(Docker.RemoteImage);
22 const output = {
23 imageRef: "nginx:alpine",
24 imageId: "sha256:0",
25 createdAt: 0,
26 name: "nginx",
27 tag: "alpine",
28 };
29
30 const pinned = yield* provider.diff!({
31 id: "nginx",
32 fqn: "nginx",
33 instanceId: "instance",
34 olds: { name: "nginx", tag: "alpine", alwaysPull: false },
35 news: { name: "nginx", tag: "alpine", alwaysPull: false },
36 oldBindings: [],
37 newBindings: [],
38 output,
39 });
40 expect(pinned).toBeUndefined();
41
42 const refreshed = yield* provider.diff!({
43 id: "nginx",
44 fqn: "nginx",
45 instanceId: "instance",
46 olds: { name: "nginx", tag: "alpine", alwaysPull: false },
47 news: { name: "nginx", tag: "alpine" },
48 oldBindings: [],
49 newBindings: [],
50 output,
51 });
52 expect(refreshed).toEqual({ action: "update" });
53 }),
54 { tags: ["provider:docker", "provider:docker:remoteimage", "local"] },
55);
56
57test.provider(
58 "diff pulls again when Docker context changes",
59 () =>
60 Effect.gen(function* () {
61 const provider = yield* Provider.findProvider(Docker.RemoteImage);
62 const output = {
63 imageRef: "nginx:alpine",
64 imageId: "sha256:0",
65 createdAt: 0,
66 name: "nginx",
67 tag: "alpine",
68 };
69
70 const changed = yield* provider.diff!({
71 id: "nginx",
72 fqn: "nginx",
73 instanceId: "instance",
74 olds: {
75 name: "nginx",
76 tag: "alpine",
77 alwaysPull: false,
78 context: "default",
79 },
80 news: {
81 name: "nginx",
82 tag: "alpine",
83 alwaysPull: false,
84 context: "remote-build",
85 },
86 oldBindings: [],
87 newBindings: [],
88 output,
89 });
90 expect(changed).toEqual({ action: "update" });
91 }),
92 { tags: ["provider:docker", "provider:docker:remoteimage", "local"] },
93);
94
95describe(
96 "Docker.RemoteImage",
97 {
98 tags: ["provider:docker", "provider:docker:remoteimage", "local"],
99 concurrent: false,
100 },
101 () => {
102 test.provider("pulls a Docker image reference", (stack) =>
103 Effect.gen(function* () {
104 const image = yield* stack.deploy(
105 Docker.RemoteImage("remote-nginx", {
106 name: "nginx",
107 tag: "alpine",
108 alwaysPull: false,
109 }),
110 );
111 expect(image.imageRef).toBe("nginx:alpine");
112 expect(image.imageId.length).toBeGreaterThan(0);
113 }),
114 );
115
116 test.provider("pulls then re-tags under a new repository", (stack) =>
117 Effect.gen(function* () {
118 const docker = yield* Docker.Docker;
119 const targetName = "alchemy-test-hello";
120 const targetTag = "retagged";
121 const targetRef = `${targetName}:${targetTag}`;
122 // RemoteImage.delete is a no-op, so reclaim the re-tagged image here.
123 yield* Effect.addFinalizer(() =>
124 docker.image.remove([targetRef], true).pipe(Effect.ignore),
125 );
126
127 const image = yield* stack.deploy(
128 Docker.RemoteImage("retagged-hello", {
129 name: "hello-world",
130 tag: "latest",
131 targetName,
132 targetTag,
133 }),
134 );
135 expect(image.imageRef).toBe(targetRef);
136 expect(image.name).toBe(targetName);
137 expect(image.tag).toBe(targetTag);
138 expect(image.imageId.length).toBeGreaterThan(0);
139
140 const inspected = yield* docker.image.inspect(targetRef);
141 expect(inspected.Id.length).toBeGreaterThan(0);
142 }),
143 );
144
145 test.provider("pulls, re-tags, and pushes to a registry", (stack) =>
146 Effect.gen(function* () {
147 const docker = yield* Docker.Docker;
148 const client = yield* HttpClient.HttpClient;
149 const port = yield* findAvailablePort();
150 const registryName = "alchemy-test-registry";
151 const host = `localhost:${port}`;
152 const targetName = `${host}/alchemy-hello`;
153 const targetTag = "v1";
154 const targetRef = `${targetName}:${targetTag}`;
155
156 yield* Effect.addFinalizer(() =>
157 Effect.all([
158 docker.run(["rm", "-f", registryName]),
159 docker.image.remove(targetRef, true),
160 ]).pipe(Effect.ignore),
161 );
162
163 yield* docker.run([
164 "run",
165 "-d",
166 "--name",
167 registryName,
168 "-p",
169 `${port}:5000`,
170 "registry:2",
171 ]);
172
173 // Wait for the registry HTTP API to start serving before pushing.
174 yield* client.get(`http://${host}/v2/`).pipe(
175 Effect.retry({
176 schedule: Schedule.exponential("250 millis"),
177 times: 20,
178 }),
179 );
180
181 const image = yield* stack.deploy(
182 Docker.RemoteImage("pushed-hello", {
183 name: "hello-world",
184 tag: "latest",
185 targetName,
186 targetTag,
187 registry: {
188 server: host,
189 username: "alchemy",
190 password: Redacted.make("ignored-by-insecure-registry"),
191 },
192 }),
193 );
194
195 expect(image.imageRef).toBe(targetRef);
196 expect(image.repoDigest).toBeDefined();
197 expect(image.repoDigest).toContain(`${targetName}@sha256:`);
198 }),
199 );
200 },
201);