1---
2title: "Part 3: Effect Jobs"
3description: Write a smoke test as an Effect program, run it as a Kubernetes Job that Alchemy builds and pushes for you, watch it re-run when the program changes, and schedule it as a CronJob.
4sidebar:
5 order: 3
6---
7
8import Terminal from "../../../../components/Terminal.astro";
9import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
10
11In [Part 2](/kubernetes/tutorial/part-2) you configured podinfo in its
12own namespace. Now you'll write your own code and run it on the
13cluster. You'll start with a smoke test, written in Effect, that
14checks podinfo is healthy after every change. Then you'll run the
15same program on a schedule.
16
17## Share resources between files
18
19The Job will live in its own file and needs the cluster, the
20namespace, and the Deployment. Move them out of `alchemy.run.ts` into
21`src/infra.ts`:
22
23```typescript
24// src/infra.ts
25import * as Kubernetes from "alchemy/Kubernetes";
26import * as Effect from "effect/Effect";
27
28export const Cluster = Kubernetes.LocalCluster("Cluster", {
29 name: "alchemy",
30});
31
32export const Namespace = Effect.gen(function* () {
33 const cluster = yield* Cluster;
34 return yield* Kubernetes.Manifest("Namespace", {
35 cluster,
36 manifest: {
37 apiVersion: "v1",
38 kind: "Namespace",
39 metadata: { name: "my-app" },
40 },
41 });
42});
43
44export const Web = Effect.gen(function* () {
45 const cluster = yield* Cluster;
46 const namespace = yield* Namespace;
47 return yield* Kubernetes.Deployment("Web", {
48 cluster,
49 name: "web",
50 namespace: namespace.name,
51 image: "ghcr.io/stefanprodan/podinfo:6.15.0",
52 port: 9898,
53 replicas: 2,
54 serviceType: "ClusterIP",
55 env: {
56 PODINFO_UI_MESSAGE: "hello from alchemy",
57 },
58 resources: {
59 requests: { cpu: "10m", memory: "32Mi" },
60 limits: { memory: "64Mi" },
61 },
62 });
63});
64```
65
66A resource declared at module scope is an Effect you can `yield*`
67from anywhere. Every `yield*` resolves to the same resource, so the
68Stack and the Job share one cluster, one namespace, and one
69Deployment.
70
71## Use them from the Stack
72
73Replace the inline declarations in `alchemy.run.ts`:
74
75```diff lang="typescript"
76import * as Alchemy from "alchemy";
77import * as Kubernetes from "alchemy/Kubernetes";
78import * as Effect from "effect/Effect";
79+import { Cluster, Web } from "./src/infra.ts";
80
81export default Alchemy.Stack(
82 "MyCluster",
83 {
84 providers: Kubernetes.providers(),
85 state: Alchemy.localState(),
86 },
87 Effect.gen(function* () {
88- const cluster = yield* Kubernetes.LocalCluster("Cluster", {
89- name: "alchemy",
90- });
91- const namespace = yield* Kubernetes.Manifest("Namespace", { ... });
92- const web = yield* Kubernetes.Deployment("Web", { ... });
93+ const cluster = yield* Cluster;
94+ const web = yield* Web;
95
96 return {
97 context: cluster.context,
98 namespace: web.namespace,
99 service: web.serviceName,
100 };
101 }),
102);
103```
104
105The logical IDs haven't changed, so a deploy now would show no
106changes. Moving code between files doesn't move resources.
107
108## Declare the Job
109
110Create `src/SmokeTest.ts`. A `Kubernetes.Job` that runs your own code
111takes two Effects, one for its props and one for its implementation:
112
113```typescript
114// src/SmokeTest.ts
115import * as Kubernetes from "alchemy/Kubernetes";
116import * as Effect from "effect/Effect";
117import { Cluster, Namespace } from "./infra.ts";
118
119export default Kubernetes.Job(
120 "SmokeTest",
121 Effect.gen(function* () {
122 const cluster = yield* Cluster;
123 const namespace = yield* Namespace;
124 return {
125 cluster,
126 main: import.meta.url,
127 name: "smoke-test",
128 namespace: namespace.name,
129 };
130 }),
131 Effect.gen(function* () {
132 return {
133 run: Effect.log("smoke test"),
134 };
135 }),
136);
137```
138
139`main: import.meta.url` says the program is this module. On deploy,
140Alchemy bundles it, builds a container image, and pushes it to the
141local cluster's registry. In the pod, a generated entrypoint runs
142`run` to completion and exits.
143
144## Bind the service URL
145
146Inside the cluster, a Service is reachable at
147`<service>.<namespace>.svc.cluster.local`. Build that URL from the
148Deployment's attributes in the Job's implementation:
149
150```diff lang="typescript"
151import * as Kubernetes from "alchemy/Kubernetes";
152+import * as Output from "alchemy/Output";
153import * as Effect from "effect/Effect";
154-import { Cluster, Namespace } from "./infra.ts";
155+import { Cluster, Namespace, Web } from "./infra.ts";
156```
157
158```diff lang="typescript"
159Effect.gen(function* () {
160+ const web = yield* Web;
161+ const webUrl = yield* Output.interpolate`http://${web.serviceName}.${web.namespace}.svc.cluster.local:${web.port}`;
162+
163 return {
164 run: Effect.log("smoke test"),
165 };
166}),
167```
168
169`Output.interpolate` builds a string from the Deployment's
170attributes. Yielding it in the implementation binds the value to the
171Job. Alchemy resolves it at deploy time, delivers it to the pod, and
172gives you `webUrl`, an Effect that returns the URL at runtime. The
173binding also makes the Job depend on the Deployment.
174
175## Check the service
176
177Now write the test. Read the bound URL and call podinfo's health
178endpoint with Effect's `HttpClient`:
179
180```diff lang="typescript"
181import * as Effect from "effect/Effect";
182+import * as HttpClient from "effect/http/HttpClient";
183+import * as HttpClientResponse from "effect/http/HttpClientResponse";
184import { Cluster, Namespace, Web } from "./infra.ts";
185```
186
187```diff lang="typescript"
188return {
189- run: Effect.log("smoke test"),
190+ run: Effect.gen(function* () {
191+ const url = yield* webUrl;
192+ const response = yield* HttpClient.get(`${url}/healthz`).pipe(
193+ Effect.flatMap(HttpClientResponse.filterStatusOk),
194+ );
195+ yield* Effect.log(`${url} is healthy (${response.status})`);
196+ }).pipe(Effect.orDie),
197};
198```
199
200`HttpClientResponse.filterStatusOk` turns any non-2xx response into
201an error.
202`Effect.orDie` turns errors into a failed process. A Job has nowhere
203to return an error to, so failing is how it reports one.
204
205## Retry while podinfo starts
206
207The Job can start before podinfo's pods are ready. Retry the request
208a few times with a backoff:
209
210```diff lang="typescript"
211import * as Effect from "effect/Effect";
212+import * as Schedule from "effect/Schedule";
213import * as HttpClient from "effect/http/HttpClient";
214```
215
216```diff lang="typescript"
217const response = yield* HttpClient.get(`${url}/healthz`).pipe(
218 Effect.flatMap(HttpClientResponse.filterStatusOk),
219+ Effect.retry({
220+ schedule: Schedule.exponential("1 second"),
221+ times: 5,
222+ }),
223);
224```
225
226## Retries and cleanup
227
228Tell Kubernetes how to treat the finished Job:
229
230```diff lang="typescript"
231return {
232 cluster,
233 main: import.meta.url,
234 name: "smoke-test",
235 namespace: namespace.name,
236+ backoffLimit: 2,
237+ ttlSecondsAfterFinished: 600,
238};
239```
240
241`backoffLimit` is how many times Kubernetes retries a failed pod
242before marking the Job failed. `ttlSecondsAfterFinished` lets
243Kubernetes delete the finished Job after ten minutes. Alchemy tracks
244the Job through its ServiceAccount, so a cleaned-up Job isn't
245treated as missing or run again.
246
247## Run it from the Stack
248
249Yield the Job in `alchemy.run.ts` and return its name:
250
251```diff lang="typescript"
252+import SmokeTest from "./src/SmokeTest.ts";
253import { Cluster, Web } from "./src/infra.ts";
254```
255
256```diff lang="typescript"
257const cluster = yield* Cluster;
258const web = yield* Web;
259+const smokeTest = yield* SmokeTest;
260
261return {
262 context: cluster.context,
263 namespace: web.namespace,
264 service: web.serviceName,
265+ smokeTest: smokeTest.jobName,
266};
267```
268
269## Deploy
270
271<Tabs syncKey="pkgManager">
272 <TabItem label="bun" icon="bun">
273 <Code code="bun alchemy deploy" lang="sh" />
274 </TabItem>
275 <TabItem label="npm" icon="npm">
276 <Code code="npm run alchemy deploy" lang="sh" />
277 </TabItem>
278 <TabItem label="pnpm" icon="pnpm">
279 <Code code="pnpm alchemy deploy" lang="sh" />
280 </TabItem>
281 <TabItem label="yarn" icon="seti:yarn">
282 <Code code="yarn alchemy deploy" lang="sh" />
283 </TabItem>
284</Tabs>
285
286<Terminal content={`[u]Plan[/u]: [g]1 to create[/g]
287
288[g]+[/g] [b]SmokeTest[/b] [d](Kubernetes.Job)[/d]
289
290Proceed?
291◉ Yes ○ No
292[d]Bundling SmokeTest program...[/d]
293[d]Building container image localhost:5001/mycluster-smoketest-live-you-…:112df477…[/d]
294[d]Pushed localhost:5001/mycluster-smoketest-live-you-…:112df477…[/d]
295[g]✓[/g] [b]SmokeTest[/b] [d](Kubernetes.Job)[/d] [g]created[/g]
296{
297[s2]context: "kind-alchemy",
298[s2]namespace: "my-app",
299[s2]service: "web",
300[s2]smokeTest: "smoke-test-4dceddd5",
301}`} />
302
303Kubernetes starts a Job as soon as it's created. The deploy doesn't
304wait for it to finish. Follow it with `kubectl`:
305
306```sh
307kubectl --context kind-alchemy -n my-app wait --for=condition=complete job/smoke-test-4dceddd5
308kubectl --context kind-alchemy -n my-app logs job/smoke-test-4dceddd5
309```
310
311```text
312Kubernetes Job bootstrap starting...
313INFO http://web.my-app.svc.cluster.local:9898 is healthy (200)
314```
315
316## Change the program
317
318The Job is named `smoke-test-4dceddd5`, not `smoke-test`. Kubernetes
319doesn't allow a Job's pod template to change after it's created, so
320Alchemy names each Job after a hash of what it runs. Change the
321program and you get a new Job. Leave it alone and nothing re-runs.
322
323Change the log message and deploy:
324
325```diff lang="typescript"
326-yield* Effect.log(`${url} is healthy (${response.status})`);
327+yield* Effect.log(`${url} responded ${response.status}`);
328```
329
330<Terminal content={`[u]Plan[/u]: [y]1 to update[/y]
331
332[y]~[/y] [b]SmokeTest[/b] [d](Kubernetes.Job)[/d]
333
334Proceed?
335◉ Yes ○ No
336[g]✓[/g] [b]SmokeTest[/b] [d](Kubernetes.Job)[/d] [y]updated[/y]
337{
338[s2]context: "kind-alchemy",
339[s2]namespace: "my-app",
340[s2]service: "web",
341[s2]smokeTest: "smoke-test-81931ef0",
342}`} />
343
344Alchemy rebuilt the image, ran `smoke-test-81931ef0`, and deleted
345the previous Job. Deploy once more without changes and the plan is
346empty.
347
348## Run it on a schedule
349
350A health check is more useful when it keeps running. Copy
351`src/SmokeTest.ts` to `src/HealthCheck.ts`, rename it, and replace
352the retry settings with a `schedule`:
353
354```diff lang="typescript"
355export default Kubernetes.Job(
356- "SmokeTest",
357+ "HealthCheck",
358 Effect.gen(function* () {
359 // ...
360 return {
361 cluster,
362 main: import.meta.url,
363- name: "smoke-test",
364+ name: "health-check",
365 namespace: namespace.name,
366- backoffLimit: 2,
367- ttlSecondsAfterFinished: 600,
368+ schedule: "*/5 * * * *",
369 };
370 }),
371```
372
373`schedule` takes a standard five-field cron expression. Setting it
374makes Alchemy create a Kubernetes `CronJob`, which starts a Job from
375the same image every five minutes. A CronJob can be updated in place, so
376it keeps the plain name `health-check`.
377
378## Deploy the CronJob
379
380Yield it from the Stack:
381
382```diff lang="typescript"
383+import HealthCheck from "./src/HealthCheck.ts";
384import SmokeTest from "./src/SmokeTest.ts";
385```
386
387```diff lang="typescript"
388const smokeTest = yield* SmokeTest;
389+yield* HealthCheck;
390```
391
392Deploy, wait for the next five-minute mark, and list the runs:
393
394```sh
395kubectl --context kind-alchemy -n my-app get cronjobs,jobs
396```
397
398```text
399NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
400cronjob.batch/health-check */5 * * * * <none> False 0 12s 4m
401
402NAME STATUS COMPLETIONS DURATION AGE
403job.batch/health-check-29835710 Complete 1/1 5s 12s
404job.batch/smoke-test-81931ef0 Complete 1/1 4s 6m
405```
406
407## Recap
408
409You now have:
410
411- Shared resources in `src/infra.ts`, used by the Stack and by the
412 Job
413- A smoke test written in Effect, built and pushed by Alchemy, that
414 runs whenever the program changes
415- The same program running every five minutes as a CronJob
416
417In [Part 4](/kubernetes/tutorial/part-4), you'll install a cluster
418add-on from a Helm chart.