Part 3: Effect Jobs
In Part 2 you configured podinfo in its own namespace. Now you’ll write your own code and run it on the cluster. You’ll start with a smoke test, written in Effect, that checks podinfo is healthy after every change. Then you’ll run the same program on a schedule.
Share resources between files
Section titled “Share resources between files”The Job will live in its own file and needs the cluster, the
namespace, and the Deployment. Move them out of alchemy.run.ts into
src/infra.ts:
import * as Kubernetes from "alchemy/Kubernetes";import * as Effect from "effect/Effect";
export const Cluster = Kubernetes.LocalCluster("Cluster", { name: "alchemy",});
export const Namespace = Effect.gen(function* () { const cluster = yield* Cluster; return yield* Kubernetes.Manifest("Namespace", { cluster, manifest: { apiVersion: "v1", kind: "Namespace", metadata: { name: "my-app" }, }, });});
export const Web = Effect.gen(function* () { const cluster = yield* Cluster; const namespace = yield* Namespace; return yield* Kubernetes.Deployment("Web", { cluster, name: "web", namespace: namespace.name, image: "ghcr.io/stefanprodan/podinfo:6.15.0", port: 9898, replicas: 2, serviceType: "ClusterIP", env: { PODINFO_UI_MESSAGE: "hello from alchemy", }, resources: { requests: { cpu: "10m", memory: "32Mi" }, limits: { memory: "64Mi" }, }, });});A resource declared at module scope is an Effect you can yield*
from anywhere. Every yield* resolves to the same resource, so the
Stack and the Job share one cluster, one namespace, and one
Deployment.
Use them from the Stack
Section titled “Use them from the Stack”Replace the inline declarations in alchemy.run.ts:
import * as Alchemy from "alchemy";import * as Kubernetes from "alchemy/Kubernetes";import * as Effect from "effect/Effect";import { Cluster, Web } from "./src/infra.ts";
export default Alchemy.Stack( "MyCluster", { providers: Kubernetes.providers(), state: Alchemy.localState(), }, Effect.gen(function* () { const cluster = yield* Kubernetes.LocalCluster("Cluster", { name: "alchemy", }); const namespace = yield* Kubernetes.Manifest("Namespace", { ... }); const web = yield* Kubernetes.Deployment("Web", { ... }); const cluster = yield* Cluster; const web = yield* Web;
return { context: cluster.context, namespace: web.namespace, service: web.serviceName, }; }),);The logical IDs haven’t changed, so a deploy now would show no changes. Moving code between files doesn’t move resources.
Declare the Job
Section titled “Declare the Job”Create src/SmokeTest.ts. A Kubernetes.Job that runs your own code
takes two Effects, one for its props and one for its implementation:
import * as Kubernetes from "alchemy/Kubernetes";import * as Effect from "effect/Effect";import { Cluster, Namespace } from "./infra.ts";
export default Kubernetes.Job( "SmokeTest", Effect.gen(function* () { const cluster = yield* Cluster; const namespace = yield* Namespace; return { cluster, main: import.meta.url, name: "smoke-test", namespace: namespace.name, }; }), Effect.gen(function* () { return { run: Effect.log("smoke test"), }; }),);main: import.meta.url says the program is this module. On deploy,
Alchemy bundles it, builds a container image, and pushes it to the
local cluster’s registry. In the pod, a generated entrypoint runs
run to completion and exits.
Bind the service URL
Section titled “Bind the service URL”Inside the cluster, a Service is reachable at
<service>.<namespace>.svc.cluster.local. Build that URL from the
Deployment’s attributes in the Job’s implementation:
import * as Kubernetes from "alchemy/Kubernetes";import * as Output from "alchemy/Output";import * as Effect from "effect/Effect";import { Cluster, Namespace } from "./infra.ts";import { Cluster, Namespace, Web } from "./infra.ts";Effect.gen(function* () { const web = yield* Web; const webUrl = yield* Output.interpolate`http://${web.serviceName}.${web.namespace}.svc.cluster.local:${web.port}`;
return { run: Effect.log("smoke test"), };}),Output.interpolate builds a string from the Deployment’s
attributes. Yielding it in the implementation binds the value to the
Job. Alchemy resolves it at deploy time, delivers it to the pod, and
gives you webUrl, an Effect that returns the URL at runtime. The
binding also makes the Job depend on the Deployment.
Check the service
Section titled “Check the service”Now write the test. Read the bound URL and call podinfo’s health
endpoint with Effect’s HttpClient:
import * as Effect from "effect/Effect";import * as HttpClient from "effect/http/HttpClient";import * as HttpClientResponse from "effect/http/HttpClientResponse";import { Cluster, Namespace, Web } from "./infra.ts";return { run: Effect.log("smoke test"), run: Effect.gen(function* () { const url = yield* webUrl; const response = yield* HttpClient.get(`${url}/healthz`).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), ); yield* Effect.log(`${url} is healthy (${response.status})`); }).pipe(Effect.orDie),};HttpClientResponse.filterStatusOk turns any non-2xx response into
an error.
Effect.orDie turns errors into a failed process. A Job has nowhere
to return an error to, so failing is how it reports one.
Retry while podinfo starts
Section titled “Retry while podinfo starts”The Job can start before podinfo’s pods are ready. Retry the request a few times with a backoff:
import * as Effect from "effect/Effect";import * as Schedule from "effect/Schedule";import * as HttpClient from "effect/http/HttpClient";const response = yield* HttpClient.get(`${url}/healthz`).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.retry({ schedule: Schedule.exponential("1 second"), times: 5, }),);Retries and cleanup
Section titled “Retries and cleanup”Tell Kubernetes how to treat the finished Job:
return { cluster, main: import.meta.url, name: "smoke-test", namespace: namespace.name, backoffLimit: 2, ttlSecondsAfterFinished: 600,};backoffLimit is how many times Kubernetes retries a failed pod
before marking the Job failed. ttlSecondsAfterFinished lets
Kubernetes delete the finished Job after ten minutes. Alchemy tracks
the Job through its ServiceAccount, so a cleaned-up Job isn’t
treated as missing or run again.
Run it from the Stack
Section titled “Run it from the Stack”Yield the Job in alchemy.run.ts and return its name:
import SmokeTest from "./src/SmokeTest.ts";import { Cluster, Web } from "./src/infra.ts";const cluster = yield* Cluster;const web = yield* Web;const smokeTest = yield* SmokeTest;
return { context: cluster.context, namespace: web.namespace, service: web.serviceName, smokeTest: smokeTest.jobName,};Deploy
Section titled “Deploy”bun alchemy deploynpm run alchemy deploypnpm alchemy deployyarn alchemy deployPlan: 1 to create + SmokeTest (Kubernetes.Job) Proceed? ◉ Yes ○ No Bundling SmokeTest program... Building container image localhost:5001/mycluster-smoketest-live-you-…:112df477… Pushed localhost:5001/mycluster-smoketest-live-you-…:112df477… ✓ SmokeTest (Kubernetes.Job) created { context: "kind-alchemy", namespace: "my-app", service: "web", smokeTest: "smoke-test-4dceddd5", }
Kubernetes starts a Job as soon as it’s created. The deploy doesn’t
wait for it to finish. Follow it with kubectl:
kubectl --context kind-alchemy -n my-app wait --for=condition=complete job/smoke-test-4dceddd5kubectl --context kind-alchemy -n my-app logs job/smoke-test-4dceddd5Kubernetes Job bootstrap starting...INFO http://web.my-app.svc.cluster.local:9898 is healthy (200)Change the program
Section titled “Change the program”The Job is named smoke-test-4dceddd5, not smoke-test. Kubernetes
doesn’t allow a Job’s pod template to change after it’s created, so
Alchemy names each Job after a hash of what it runs. Change the
program and you get a new Job. Leave it alone and nothing re-runs.
Change the log message and deploy:
yield* Effect.log(`${url} is healthy (${response.status})`);yield* Effect.log(`${url} responded ${response.status}`);Plan: 1 to update ~ SmokeTest (Kubernetes.Job) Proceed? ◉ Yes ○ No ✓ SmokeTest (Kubernetes.Job) updated { context: "kind-alchemy", namespace: "my-app", service: "web", smokeTest: "smoke-test-81931ef0", }
Alchemy rebuilt the image, ran smoke-test-81931ef0, and deleted
the previous Job. Deploy once more without changes and the plan is
empty.
Run it on a schedule
Section titled “Run it on a schedule”A health check is more useful when it keeps running. Copy
src/SmokeTest.ts to src/HealthCheck.ts, rename it, and replace
the retry settings with a schedule:
export default Kubernetes.Job( "SmokeTest", "HealthCheck", Effect.gen(function* () { // ... return { cluster, main: import.meta.url, name: "smoke-test", name: "health-check", namespace: namespace.name, backoffLimit: 2, ttlSecondsAfterFinished: 600, schedule: "*/5 * * * *", }; }),schedule takes a standard five-field cron expression. Setting it
makes Alchemy create a Kubernetes CronJob, which starts a Job from
the same image every five minutes. A CronJob can be updated in place, so
it keeps the plain name health-check.
Deploy the CronJob
Section titled “Deploy the CronJob”Yield it from the Stack:
import HealthCheck from "./src/HealthCheck.ts";import SmokeTest from "./src/SmokeTest.ts";const smokeTest = yield* SmokeTest;yield* HealthCheck;Deploy, wait for the next five-minute mark, and list the runs:
kubectl --context kind-alchemy -n my-app get cronjobs,jobsNAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGEcronjob.batch/health-check */5 * * * * <none> False 0 12s 4m
NAME STATUS COMPLETIONS DURATION AGEjob.batch/health-check-29835710 Complete 1/1 5s 12sjob.batch/smoke-test-81931ef0 Complete 1/1 4s 6mYou now have:
- Shared resources in
src/infra.ts, used by the Stack and by the Job - A smoke test written in Effect, built and pushed by Alchemy, that runs whenever the program changes
- The same program running every five minutes as a CronJob
In Part 4, you’ll install a cluster add-on from a Helm chart.