1import * as GCP from "alchemy/GCP";
2import * as Kubernetes from "alchemy/Kubernetes";
3import * as Effect from "effect/Effect";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import {
7 EntriesDatabase,
8 entryPath,
9 GuestbookCluster,
10 GuestbookNamespace,
11} from "./infra.ts";
12
13/**
14 * The guestbook API: a `Kubernetes.Deployment` in the TAGGED form — the class
15 * declares the deployment identity, and the default export is
16 * `Api.make(props, impl)`: a Layer pairing the props with an init Effect
17 * whose impl returns `{ fetch }`, bundled into a generated image
18 * (`main: import.meta.url`). The Deployment synthesizes the Kubernetes
19 * Deployment + Service + ServiceAccount (server-side apply) and an Artifact
20 * Registry repository holding the image.
21 *
22 * The stack provides the Layer and yields the class (see `alchemy.run.ts`):
23 *
24 * ```typescript
25 * const api = yield* Api; // with Effect.provide(ApiLive)
26 * ```
27 *
28 * `serviceType: "LoadBalancer"` has GKE provision an external passthrough
29 * Network Load Balancer; `api.url` is the full URL INCLUDING the Service
30 * port (`http://<ip>:3000` — Kubernetes maps `spec.ports[].port` 1:1 to the
31 * cloud listener).
32 *
33 * Bindings work exactly as on Cloud Run:
34 * - `GCP.Firestore.ReadWriteDatabase` grants `roles/datastore.user` —
35 * project-level, under an IAM Condition naming this database — directly
36 * to the Deployment's Kubernetes ServiceAccount principal
37 * (`principal://…/subject/ns/guestbook/sa/<ksa>`), and injects the
38 * database name into the pod. At runtime the pod's GKE metadata server
39 * hands out Workload Identity Federation tokens for that principal — no
40 * keys, no Google service account.
41 *
42 * `podTemplate` is the Kubernetes escape hatch: a literal deep-partial Pod
43 * template merged into the synthesized one (objects merge recursively;
44 * arrays and primitives replace).
45 */
46export class Api extends Kubernetes.Deployment<Api>()("Api") {}
47
48export default Api.make(
49 // Props are themselves an Effect so they can reference shared resources.
50 Effect.gen(function* () {
51 const cluster = yield* GuestbookCluster;
52 const ns = yield* GuestbookNamespace;
53 return {
54 cluster,
55 main: import.meta.url,
56 // Reference the Manifest's attribute (not a bare string) so the
57 // Deployment depends on — and deploys after — the namespace.
58 namespace: ns.name,
59 port: 3000,
60 replicas: 2,
61 serviceType: "LoadBalancer" as const,
62 // Autopilot sizes nodes from requests (and pins limits to them).
63 resources: {
64 requests: { cpu: "250m", memory: "512Mi" },
65 limits: { cpu: "250m", memory: "512Mi" },
66 },
67 // Kubernetes escape hatch: tune the synthesized Pod template with a
68 // literal deep-partial object merged onto it.
69 podTemplate: {
70 metadata: {
71 annotations: {
72 "prometheus.io/scrape": "true",
73 "prometheus.io/port": "3000",
74 },
75 },
76 spec: { terminationGracePeriodSeconds: 30 },
77 },
78 };
79 }),
80 Effect.gen(function* () {
81 const db = yield* GCP.Firestore.ReadWriteDatabase(EntriesDatabase);
82
83 return {
84 fetch: Effect.gen(function* () {
85 const request = yield* HttpServerRequest;
86 const url = new URL(request.url, "http://guestbook");
87
88 // GET /entries — list every entry.
89 if (request.method === "GET" && url.pathname === "/entries") {
90 const page = yield* db.list("entries", { pageSize: 300 });
91 const entries = page.documents.map((document) => ({
92 id: document.name.split("/").pop(),
93 author: document.fields.author,
94 message: document.fields.message,
95 }));
96 return yield* HttpServerResponse.json({
97 count: entries.length,
98 entries,
99 });
100 }
101
102 // GET /entries/<id> — read one entry.
103 const match = url.pathname.match(/^\/entries\/([^/]+)$/);
104 if (request.method === "GET" && match) {
105 const document = yield* db.get(entryPath(match[1]!));
106 if (document === undefined) {
107 return yield* HttpServerResponse.json(
108 { error: "not found" },
109 { status: 404 },
110 );
111 }
112 return yield* HttpServerResponse.json({
113 id: match[1],
114 author: document.fields.author,
115 message: document.fields.message,
116 });
117 }
118
119 // POST /entries?author=ada&message=hi — sign the guestbook.
120 if (request.method === "POST" && url.pathname === "/entries") {
121 const author = url.searchParams.get("author") ?? "anonymous";
122 const message = url.searchParams.get("message") ?? "";
123 const id = yield* Effect.sync(() => crypto.randomUUID().slice(0, 8));
124 yield* db.set(entryPath(id), { author, message });
125 return yield* HttpServerResponse.json({ id, author, message });
126 }
127
128 // Everything else — including "/" health probes.
129 return yield* HttpServerResponse.json({
130 ok: true,
131 service: "guestbook-api",
132 });
133 }).pipe(Effect.orDie),
134 };
135 }).pipe(Effect.provide(GCP.Firestore.ReadWriteDatabaseHttp)),
136);