diff --git a/examples/kubernetes-local/README.md b/examples/kubernetes-local/README.md new file mode 100644 index 0000000000..4e30467e62 --- /dev/null +++ b/examples/kubernetes-local/README.md @@ -0,0 +1,36 @@ +# Kubernetes (local cluster) Example + +The end state of the [Kubernetes tutorial](https://alchemy.run/kubernetes/tutorial/part-1) +on any cluster your kubeconfig can reach — Docker Desktop, OrbStack, kind, k3s — with +no cloud account and no container registry: + +- a `tutorial` Namespace, an `app-config` ConfigMap, and a Redis StatefulSet + Service, + all as `Kubernetes.Manifest` +- an echo server as a `Kubernetes.Deployment` (pre-built `image:`), wired to the + ConfigMap and Redis through `env` +- a one-shot `Kubernetes.Job` and a `Kubernetes.Job` with `schedule` (a CronJob) +- the `podinfo` Helm chart as a `Kubernetes.HelmChart`, rendered with the local + `helm` CLI and applied as objects + +## Commands + +```sh +bun install +bun run --filter kubernetes-local-example deploy +bun run --filter kubernetes-local-example destroy +``` + +`alchemy.run.ts` uses your kubeconfig's current context; pass +`Kubernetes.KubeConfig({ context: "docker-desktop" })` to pin one. `helm` must be +installed for the chart. + +## Try it + +```sh +# Docker Desktop / OrbStack publish the LoadBalancer on localhost: +curl "$url/hello" + +# kind / minikube have no LoadBalancer controller — port-forward instead: +kubectl -n tutorial port-forward "svc/$deployment" 8080:8080 & +curl localhost:8080/hello +``` diff --git a/examples/kubernetes-local/alchemy.run.ts b/examples/kubernetes-local/alchemy.run.ts new file mode 100644 index 0000000000..97ba5265cf --- /dev/null +++ b/examples/kubernetes-local/alchemy.run.ts @@ -0,0 +1,146 @@ +import * as Alchemy from "alchemy"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; + +// Settings shared between the ConfigMap and the Deployment's env. +const settings = { + LOG_LEVEL: "debug", + GREETING: "hello from alchemy", +}; + +// The end state of the Kubernetes tutorial (parts 1–4), on whichever +// cluster your kubeconfig's current context points at: a Namespace, +// a ConfigMap, a Redis StatefulSet + Service, an echo Deployment, a +// one-shot Job, a CronJob, and a Helm chart — all pre-built images, +// so no registry is needed. +export default Alchemy.Stack( + "KubernetesLocalExample", + { + providers: Kubernetes.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + // Any cluster `kubectl` can reach. Pass `{ context: "..." }` to pin one. + const cluster = Kubernetes.KubeConfig(); + + const ns = yield* Kubernetes.Manifest("Namespace", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Namespace", + metadata: { name: "tutorial" }, + }, + }); + + const config = yield* Kubernetes.Manifest("Config", { + cluster, + manifest: { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: "app-config", namespace: ns.name }, + data: settings, + }, + }); + + const redis = yield* Kubernetes.Manifest("Redis", { + cluster, + manifest: { + apiVersion: "apps/v1", + kind: "StatefulSet", + metadata: { name: "redis", namespace: ns.name }, + spec: { + serviceName: "redis", + replicas: 1, + selector: { matchLabels: { app: "redis" } }, + template: { + metadata: { labels: { app: "redis" } }, + spec: { + containers: [ + { + name: "redis", + image: "redis:7", + ports: [{ containerPort: 6379 }], + }, + ], + }, + }, + }, + }, + }); + + const redisService = yield* Kubernetes.Manifest("RedisService", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Service", + metadata: { name: "redis", namespace: ns.name }, + spec: { + type: "ClusterIP", + selector: { app: "redis" }, + ports: [{ port: 6379, targetPort: 6379 }], + }, + }, + }); + + // A replicated server from a pre-built image. `LoadBalancer` (the + // default) populates `url` on clusters with an LB implementation + // (Docker Desktop, OrbStack); kind/minikube leave it undefined — + // use NodePort or `kubectl port-forward` there. + const echo = yield* Kubernetes.Deployment("Echo", { + cluster, + namespace: ns.name, + image: "mendhak/http-https-echo:33", + port: 8080, + replicas: 3, + env: { + ...settings, + CONFIG_MAP: config.name, + REDIS_URL: Output.interpolate`redis://${redisService.name}:6379`, + }, + }); + + // Run-to-completion work. + const hello = yield* Kubernetes.Job("Hello", { + cluster, + namespace: ns.name, + image: "busybox:1.36", + command: ["sh", "-c"], + args: ["echo hello from a Job && sleep 2"], + backoffLimit: 1, + ttlSecondsAfterFinished: 300, + }); + + // ...and the same thing on a schedule (a CronJob). + const tick = yield* Kubernetes.Job("Tick", { + cluster, + namespace: ns.name, + image: "busybox:1.36", + command: ["sh", "-c", "date"], + schedule: "*/5 * * * *", + }); + + // A third-party Helm chart, rendered locally and applied as objects. + const podinfo = yield* Kubernetes.HelmChart("Podinfo", { + cluster, + chart: "podinfo", + repo: "https://stefanprodan.github.io/podinfo", + version: "6.14.1", + namespace: "podinfo", + createNamespace: true, + values: { + replicaCount: 2, + ui: { message: "hello from alchemy" }, + }, + }); + + return { + url: echo.url, + deployment: echo.deploymentName, + redis: redis.name, + job: hello.jobName, + cron: tick.jobName, + release: podinfo.releaseName, + }; + }), +); diff --git a/examples/kubernetes-local/package.json b/examples/kubernetes-local/package.json new file mode 100644 index 0000000000..d60a763fdd --- /dev/null +++ b/examples/kubernetes-local/package.json @@ -0,0 +1,21 @@ +{ + "name": "kubernetes-local-example", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy.git", + "directory": "examples/kubernetes-local" + }, + "scripts": { + "build": "tsc -b", + "deploy": "alchemy deploy", + "destroy": "alchemy destroy" + }, + "dependencies": { + "@effect/platform-node": "catalog:effect", + "alchemy": "workspace:*", + "effect": "catalog:effect" + } +} diff --git a/examples/kubernetes-local/tsconfig.json b/examples/kubernetes-local/tsconfig.json new file mode 100644 index 0000000000..586e7d2229 --- /dev/null +++ b/examples/kubernetes-local/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["alchemy.run.ts", "src/**/*.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/packages/alchemy/src/Kubernetes/HelmChart.ts b/packages/alchemy/src/Kubernetes/HelmChart.ts index f5e3eb8dce..27618fc719 100644 --- a/packages/alchemy/src/Kubernetes/HelmChart.ts +++ b/packages/alchemy/src/Kubernetes/HelmChart.ts @@ -206,7 +206,9 @@ const resolveReleaseName = ( Effect.suspend(() => { if (news.releaseName) return Effect.succeed(news.releaseName); if (output?.releaseName) return Effect.succeed(output.releaseName); - return createPhysicalName({ id, lowercase: true }); + // Helm caps release names at 53 characters (they prefix every object + // name the chart renders, which must stay a valid DNS label). + return createPhysicalName({ id, lowercase: true, maxLength: 53 }); }); /** diff --git a/packages/alchemy/src/Kubernetes/internal/client.ts b/packages/alchemy/src/Kubernetes/internal/client.ts index 359612a7ea..91e7d9f93e 100644 --- a/packages/alchemy/src/Kubernetes/internal/client.ts +++ b/packages/alchemy/src/Kubernetes/internal/client.ts @@ -324,7 +324,11 @@ export const deleteObject = Effect.fn(function* ({ requestJson({ transport, method: "DELETE", - path, + // The REST default propagation policy is per-kind — batch/v1 Jobs + // (and CronJobs) default to `Orphan`, which leaves their pods + // behind on delete. Ask for background cascading deletion the way + // `kubectl delete` does so dependents are garbage-collected. + path: `${path}?propagationPolicy=Background`, }), ), Effect.catchIf( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50449da426..fe22158eee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2424,6 +2424,18 @@ importers: specifier: 4.0.0-rc.110 version: 4.0.0-rc.110 + examples/kubernetes-local: + dependencies: + '@effect/platform-node': + specifier: catalog:effect + version: 4.0.0-rc.110(effect@4.0.0-rc.110)(redis@6.2.1) + alchemy: + specifier: workspace:* + version: link:../../packages/alchemy + effect: + specifier: 4.0.0-rc.110 + version: 4.0.0-rc.110 + examples/monorepo-multi-stack/backend: dependencies: '@effect/platform-bun': diff --git a/tsconfig.json b/tsconfig.json index 981714e1e7..9242ba5fa7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -82,6 +82,9 @@ { "path": "./examples/aws-eks/tsconfig.json" }, + { + "path": "./examples/kubernetes-local/tsconfig.json" + }, { "path": "./examples/aws-bedrock-ai/tsconfig.json" }, diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 219f6ba45c..ac534d7675 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -27,6 +27,7 @@ function providersSidebarEntry() { { label: "Cloudflare", link: "/cloudflare" }, { label: "Hetzner", link: "/hetzner" }, { label: "Fly", link: "/fly" }, + { label: "Kubernetes", link: "/kubernetes" }, { label: "PlanetScale", link: "/planetscale" }, { label: "Neon", link: "/neon" }, { label: "Prisma", link: "/prisma" }, @@ -1022,6 +1023,72 @@ export default defineConfig({ providerResourcesEntry("Fly"), ], }, + { + label: "Kubernetes", + items: [ + { label: "Overview", link: "/kubernetes" }, + { label: "Setup", link: "/kubernetes/setup" }, + { + label: "Tutorial", + items: [{ autogenerate: { directory: "kubernetes/tutorial" } }], + }, + { + label: "Clusters", + items: [ + { + label: "Connecting", + link: "/kubernetes/clusters/connecting", + }, + { label: "Local clusters", link: "/kubernetes/clusters/local" }, + { label: "Amazon EKS", link: "/kubernetes/clusters/eks" }, + ], + }, + { + label: "Workloads", + items: [ + { + label: "Deployments", + link: "/kubernetes/workloads/deployments", + }, + { label: "Jobs", link: "/kubernetes/workloads/jobs" }, + { + label: "Image sources", + link: "/kubernetes/workloads/image-sources", + }, + { label: "Bindings", link: "/kubernetes/workloads/bindings" }, + { + label: "Pod template", + link: "/kubernetes/workloads/pod-template", + }, + ], + }, + { + label: "Objects", + items: [ + { label: "Manifests", link: "/kubernetes/objects/manifests" }, + { label: "Helm charts", link: "/kubernetes/objects/helm" }, + ], + }, + { + label: "Guides", + items: [ + { + label: "Exposing services", + link: "/kubernetes/guides/exposing-services", + }, + { + label: "How apply works", + link: "/kubernetes/guides/how-apply-works", + }, + { + label: "Cluster adapters", + link: "/kubernetes/guides/cluster-adapters", + }, + ], + }, + providerResourcesEntry("Kubernetes"), + ], + }, { label: "PlanetScale", items: [ diff --git a/website/src/components/ProviderDirectory.astro b/website/src/components/ProviderDirectory.astro index 1f4363e242..2eea22c62c 100644 --- a/website/src/components/ProviderDirectory.astro +++ b/website/src/components/ProviderDirectory.astro @@ -66,6 +66,10 @@ const HUBS: Record = { href: "/fly", blurb: "Machines, Services, Volumes, Secrets, and fly.dev on Fly.io", }, + Kubernetes: { + href: "/kubernetes", + blurb: "Deployments, Jobs, Manifests, and Helm charts on any cluster", + }, Planetscale: { href: "/planetscale", blurb: "Serverless MySQL & Postgres with branching workflows", diff --git a/website/src/content/docs/aws/compute/choosing-a-runtime.mdx b/website/src/content/docs/aws/compute/choosing-a-runtime.mdx index 7c7d649009..ccc8bc6b49 100644 --- a/website/src/content/docs/aws/compute/choosing-a-runtime.mdx +++ b/website/src/content/docs/aws/compute/choosing-a-runtime.mdx @@ -77,16 +77,21 @@ load-balancer integration. Alchemy models it with: - [`Cluster`](/providers/aws/eks/cluster) — the control plane; `compute: "auto"` turns on Auto Mode with sensible defaults. -- [`Deployment`](/providers/kubernetes/deployment) — a replicated +- [`Deployment`](/kubernetes/workloads/deployments) — a replicated Kubernetes server (the Kubernetes analog of `AWS.ECS.Service`) with the same three image sources as ECS: a registry `image`, a Dockerfile `context`, or an inline Effect program via `main`. -- [`Job`](/providers/kubernetes/job) — run-to-completion work, or a +- [`Job`](/kubernetes/workloads/jobs) — run-to-completion work, or a `CronJob` when you set `schedule`. -- [`Manifest`](/providers/kubernetes/manifest) and - [`HelmChart`](/providers/kubernetes/helmchart) — any raw Kubernetes +- [`Manifest`](/kubernetes/objects/manifests) and + [`HelmChart`](/kubernetes/objects/helm) — any raw Kubernetes object or a rendered Helm chart, applied via server-side apply. +The workloads are cluster-agnostic — the same program runs on any +cluster your kubeconfig can reach; see the +[Kubernetes hub](/kubernetes) for the workloads themselves and +[Amazon EKS](/kubernetes/clusters/eks) for what the EKS adapter adds. + Bindings work the same as on Lambda and ECS — env vars plus IAM policy statements, delivered through an EKS Pod Identity role — and there is no YAML and no `kubectl` step. You pay for the diff --git a/website/src/content/docs/aws/compute/eks.mdx b/website/src/content/docs/aws/compute/eks.mdx index 2a0bed8bbe..e71361e102 100644 --- a/website/src/content/docs/aws/compute/eks.mdx +++ b/website/src/content/docs/aws/compute/eks.mdx @@ -1,6 +1,6 @@ --- title: EKS -description: Stand up an EKS Auto Mode cluster on a Network and run containers on it — Deployments for servers, Jobs for run-to-completion work, Manifests for everything else. No YAML, no kubectl. +description: Stand up an EKS Auto Mode cluster on a Network, grant access, install add-ons, and point the cluster-agnostic Kubernetes workloads at it. No YAML, no kubectl. --- **EKS** (Elastic Kubernetes Service) is AWS's managed Kubernetes: @@ -27,15 +27,12 @@ Alchemy models these directly: [`Cluster`](/providers/aws/eks/cluster) with `compute: "auto"` stands up the control plane from a VPC, and the cluster-agnostic `alchemy/Kubernetes` workloads target it by passing the cluster -resource as their `cluster` prop: -[`Kubernetes.Deployment`](/providers/kubernetes/deployment) -synthesizes a Kubernetes `Deployment` + `Service`; -[`Kubernetes.Job`](/providers/kubernetes/job) a `Job` or `CronJob`; -and [`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies -any raw Kubernetes object. The workloads live in the same TypeScript -program as the cluster, with no YAML and no `kubectl apply` step — -and the same workloads run on any other cluster your kubeconfig can -reach (`Kubernetes.KubeConfig(...)`). +resource as their `cluster` prop. This page covers the cluster — +provisioning, access, add-ons, and HyperPod. Everything about the +workloads themselves lives in the +[Kubernetes hub](/kubernetes): what the EKS adapter adds on top of +any other cluster is summarized on +[Amazon EKS](/kubernetes/clusters/eks). The workload providers ship in `Kubernetes.providers()` — compose it with `AWS.providers()` in the Stack: @@ -120,47 +117,17 @@ The deploying principal is bootstrapped as cluster admin Mode defaults), so these are for everyone (and everything) else that needs in. -## Deploy a server +## Run workloads on the cluster -[`Kubernetes.Deployment`](/providers/kubernetes/deployment) is a -replicated Kubernetes server — the Kubernetes analog of -`AWS.ECS.Service`. It -synthesizes a Kubernetes `Deployment` + `Service` (+ -`ServiceAccount`) and applies them via server-side apply, with the -container image coming from exactly one of three sources flat on -props: `image` (a registry reference, mirrored into ECR), `context` -(build your own Dockerfile), or `main` (bundle an inline Effect -program). The simplest form runs a remote image with no Effect -runtime in the container: - -```typescript -const echo = yield* Kubernetes.Deployment("EchoServer", { - cluster, - image: "registry.k8s.io/echoserver:1.10", - namespace: "default", - replicas: 2, - port: 8080, - serviceType: "LoadBalancer", -}); - -echo.url; // LoadBalancer hostname (an NLB on Auto Mode) -echo.deploymentName; // K8s-native attrs: deploymentName, serviceName, ... -``` - -`serviceType: "LoadBalancer"` provisions a cloud load balancer and -exposes its hostname as `url`. Swap `image` for -`context: "./legacy"` to build your own Dockerfile (`dockerfile` is -a path, defaulting to `${context}/Dockerfile`). - -## Effect servers with bindings - -Pass `main: import.meta.url` and an init Effect and the program is -bundled into a generated image instead — the same authoring model as -[Lambda](/aws/compute/lambda). Bindings work identically too: -`Deployment` accepts the same `{ env, policyStatements }` binding -contract as a Lambda `Function`, so every AWS `Binding.Service` -attaches environment variables to the Pod spec and IAM policy -statements to a generated **Pod Identity role**: +Pass the cluster resource as the `cluster` prop of any +`alchemy/Kubernetes` workload. Because the target is an EKS cluster, +the [`aws-eks` adapter](/kubernetes/clusters/eks#what-eks-adds) +adds what a plain kubeconfig cluster can't: authentication with your +AWS credentials (no kubeconfig step), a per-workload **ECR +repository** so `main:` and `context:` image sources work, and +**Pod Identity** so AWS bindings land IAM policy statements on a +generated role — the same `{ env, policyStatements }` contract as +[Lambda](/aws/compute/lambda): ```typescript const api = yield* Kubernetes.Deployment( @@ -176,101 +143,23 @@ const api = yield* Kubernetes.Deployment( }; }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)), ); -``` -Every Kubernetes workload on EKS gets Pod Identity as standard: -Alchemy creates -the IAM role, wires it to the workload's ServiceAccount with a -[`PodIdentityAssociation`](/providers/aws/eks/podidentityassociation), -and Pods resolve credentials through the EKS Pod Identity -container-credentials chain — no OIDC provider or IRSA annotation -ceremony. The tagged form -(`class Api extends Kubernetes.Deployment()("Api") {}` + -`Api.make(props, impl)`) works exactly as it does on Lambda and -Cloudflare Workers. - -## Run-to-completion work with Job - -[`Kubernetes.Job`](/providers/kubernetes/job) runs a container to -completion — the Kubernetes analog of `AWS.ECS.Task`. Same three image sources, -same bindings and Pod Identity; an Effect impl returns `{ run }` -instead of `{ fetch }`, executing to completion inside the Pod: - -```typescript -const migrate = yield* Kubernetes.Job("DbMigrate", { - cluster, - image: "ghcr.io/acme/migrator:v3", - backoffLimit: 2, -}); +api.url; // internet-facing NLB hostname (EKS Auto Mode) ``` -Set `schedule` (standard 5-field cron) and a Kubernetes `CronJob` is -synthesized instead of a plain `Job`: - -```typescript -const nightly = yield* Kubernetes.Job("NightlyBackfill", { - cluster, - main: import.meta.url, - schedule: "0 3 * * *", -}); -``` - -## Everything else is a Manifest - -[`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies any -raw Kubernetes object — StatefulSets, Namespaces, CRDs — via server-side apply. -The manifest is a literal object, exactly as you would write it in -YAML: - -```typescript -const namespace = yield* Kubernetes.Manifest("DemoNamespace", { - cluster, - manifest: { - apiVersion: "v1", - kind: "Namespace", - metadata: { name: "demo" }, - }, -}); -``` - -There is no kubeconfig step: Alchemy authenticates to the cluster's -API with your AWS credentials (a presigned STS token) and applies -objects via **server-side apply** under the `alchemy` field manager, -so deploys converge the live objects the same way the rest of your -Stack converges cloud resources. Unknown kinds resolve through the -Kubernetes API discovery endpoint, so CRDs work without any -registration. - -## Install a Helm chart - -[`Kubernetes.HelmChart`](/providers/kubernetes/helmchart) renders a -chart with the -local `helm` CLI (`helm template` — install helm on your machine, -like Docker for image builds) and applies the rendered objects -through the same server-side-apply path as `Manifest`: - -```typescript -const ingress = yield* Kubernetes.HelmChart("IngressNginx", { - cluster, - chart: "ingress-nginx", - repo: "https://kubernetes.github.io/ingress-nginx", - version: "4.11.2", - namespace: "ingress-nginx", - createNamespace: true, - values: { - controller: { replicaCount: 2 }, - }, -}); -``` +The same program runs against any other cluster by swapping +`cluster`; the hub explains each workload in full: -`chart` also accepts `oci://` references and local chart directories, -and `values` is a literal object — the same shape as a `values.yaml` -file. Because the objects are applied (not `helm install`ed), Alchemy -owns their lifecycle: drift is corrected on every deploy, objects -that drop out of the render are pruned, and destroy deletes them — -there is no in-cluster Helm release record. Charts that rely on -install/upgrade hooks for correctness should be installed with Helm -directly. +- [Deployments](/kubernetes/workloads/deployments) — a replicated + server (the Kubernetes analog of `AWS.ECS.Service`): Deployment + + Service + ServiceAccount, `serviceType` and `url`, Effect servers. +- [Jobs](/kubernetes/workloads/jobs) — run-to-completion work (the + analog of `AWS.ECS.Task`); set `schedule` for a CronJob. +- [Bindings](/kubernetes/workloads/bindings#pod-identity-on-eks) — + how bindings become a Pod Identity role and pod environment. +- [Manifests](/kubernetes/objects/manifests) and + [Helm charts](/kubernetes/objects/helm) — any raw object, and + charts rendered with `helm template` and applied as objects. ## Run on SageMaker HyperPod @@ -333,6 +222,10 @@ surface. ## Where next +- [Kubernetes hub](/kubernetes) — the workloads and objects you run + on this cluster, on any cluster. +- [Amazon EKS in the Kubernetes hub](/kubernetes/clusters/eks) — + exactly what the EKS adapter adds (STS auth, Pod Identity, ECR). - [VPC & networking](/aws/networking) — what the `Network` helper builds under the cluster. - [HyperPod](/aws/compute/hyperpod) — run training fleets on this @@ -340,7 +233,6 @@ surface. - [Choosing a runtime](/aws/compute/choosing-a-runtime) — when Lambda or ECS is the better fit than running your own Kubernetes workloads. - [`Cluster` reference](/providers/aws/eks/cluster), - [`Deployment` reference](/providers/kubernetes/deployment), - [`Job` reference](/providers/kubernetes/job), - [`Manifest` reference](/providers/kubernetes/manifest) — every prop - and attribute. + [`AccessEntry` reference](/providers/aws/eks/accessentry), + [`Addon` reference](/providers/aws/eks/addon) — every prop and + attribute. diff --git a/website/src/content/docs/aws/index.mdx b/website/src/content/docs/aws/index.mdx index 79f808add0..8217eede48 100644 --- a/website/src/content/docs/aws/index.mdx +++ b/website/src/content/docs/aws/index.mdx @@ -101,7 +101,7 @@ Not sure which? See | Background jobs | [SQS](/aws/messaging/sqs) + Lambda | | Scheduled jobs | [EventBridge Scheduler](/aws/messaging/eventbridge) → Lambda | | A Postgres database | [RDS & Aurora](/aws/data/rds) | -| Kubernetes workloads or Helm charts | [EKS](/aws/compute/eks) | +| Kubernetes workloads or Helm charts | [EKS](/aws/compute/eks) + the [Kubernetes hub](/kubernetes) | | Object processing | [S3 events](/aws/messaging/s3-events) | | Sending transactional email | [SES sending](/aws/email/sending) — identity + `SendEmail` binding | | Inbound email pipelines | [SES email receiving](/aws/email/receiving) → S3 / SNS / Lambda | diff --git a/website/src/content/docs/kubernetes/clusters/connecting.mdx b/website/src/content/docs/kubernetes/clusters/connecting.mdx new file mode 100644 index 0000000000..02026e4dd3 --- /dev/null +++ b/website/src/content/docs/kubernetes/clusters/connecting.mdx @@ -0,0 +1,186 @@ +--- +title: Connecting to a cluster +description: Point a Kubernetes workload at a cluster using a kubeconfig context, a token, a client certificate, an exec plugin, or an EKS cluster. +--- + +Every `Kubernetes.*` resource takes a `cluster` prop: which cluster to +deploy to and how to authenticate. Pass a kubeconfig context, a token, +a client certificate, an exec plugin, or an EKS cluster. + +## What `cluster` accepts + +`cluster` is a `Connection`: the API server plus one `auth` kind. + +```typescript +// What `cluster` accepts (alchemy/Kubernetes): +interface Connection { + endpoint?: string; // API server URL; not needed for kubeconfig or aws-eks + certificateAuthorityData?: string; // base64 PEM bundle + insecureSkipTlsVerify?: boolean; // self-signed local clusters + auth: // pick one + | { kind: "kubeconfig"; path?: string; context?: string } + | { kind: "token"; token: string } + | { kind: "client-cert"; certificate: string; key: string } + | { kind: "exec"; command: string; args?: string[]; env?: Record } + | { kind: "aws-eks"; clusterName: string; region?: string }; // from alchemy/AWS +} +``` + +You can also pass an [`AWS.EKS.Cluster`](/providers/aws/eks/cluster) directly. + +```typescript +// Both of these are a valid `cluster`: +Effect.gen(function* () { + // 1. a kubeconfig context + const local = Kubernetes.KubeConfig({ context: "docker-desktop" }); + + // 2. an EKS cluster + const eks = yield* AWS.EKS.Cluster("Cluster", { compute: "auto", /* ... */ }); + + yield* Kubernetes.Deployment("Api", { cluster: local, image: "ghcr.io/acme/api:v3" }); + yield* Kubernetes.Deployment("ApiProd", { cluster: eks, image: "ghcr.io/acme/api:v3" }); +}); +``` + +## KubeConfig + +`Kubernetes.KubeConfig()` connects to any cluster in your kubeconfig. + +```typescript +Effect.gen(function* () { + // default file + current-context: fine on a laptop, risky in a checked-in stack + const cluster = Kubernetes.KubeConfig(); + + // pin the context; the file still comes from $KUBECONFIG or ~/.kube/config + const staging = Kubernetes.KubeConfig({ context: "staging-eu" }); + + // a dedicated file, e.g. one CI writes from a secret + const ci = Kubernetes.KubeConfig({ path: "./.kube/ci-config", context: "ci" }); + + yield* Kubernetes.Manifest("Namespace", { + cluster: staging, + manifest: { apiVersion: "v1", kind: "Namespace", metadata: { name: "demo" } }, + }); +}); +``` + +Without `path`, the file is the first entry of `$KUBECONFIG`, else +`~/.kube/config`. Without `context`, it is the file's +`current-context`. Always pass `context` in a checked-in stack so a +teammate's current context never retargets a deploy. + +```typescript +// Exec plugins in the file just work (GKE, AKS, EKS), with no extra configuration: +const gke = Kubernetes.KubeConfig({ context: "gke_my-project_europe-west1_prod" }); +``` + +An `aws eks update-kubeconfig` context only authenticates. For Pod +Identity and ECR, +[target the EKS cluster itself](/kubernetes/clusters/eks#targeting-an-eks-cluster). + +## Token, client certificate, and exec + +These connect without a kubeconfig file. Set `endpoint`, plus either +`certificateAuthorityData` (base64 PEM) or, for a +[self-signed local cluster](/kubernetes/clusters/local#which-local-cluster), +`insecureSkipTlsVerify: true`. + +### token + +Sends a bearer token. Read it from config, never paste a literal. + +```typescript +Effect.gen(function* () { + const token = yield* Config.string("KUBE_TOKEN"); + const ca = yield* Config.string("KUBE_CA_DATA"); // base64 PEM, as in a kubeconfig + + const cluster: Kubernetes.Connection = { + endpoint: "https://10.0.0.10:6443", + certificateAuthorityData: ca, + auth: { kind: "token", token }, + }; + + yield* Kubernetes.Deployment("Api", { cluster, image: "ghcr.io/acme/api:v3", port: 8080 }); +}); +``` + +### client-cert + +Pass the certificate and key as PEM text, not base64. + +```typescript +Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cluster: Kubernetes.Connection = { + endpoint: "https://k3s.lan:6443", + insecureSkipTlsVerify: true, // self-signed server cert + auth: { + kind: "client-cert", + certificate: yield* fs.readFileString("./certs/client.crt"), // PEM + key: yield* fs.readFileString("./certs/client.key"), // PEM + }, + }; + yield* Kubernetes.Job("Migrate", { cluster, image: "ghcr.io/acme/migrator:v3" }); +}); +``` + +### exec + +Runs a credential plugin, the same way kubectl does. If the plugin +fails you'll see `Kubernetes.ExecCredentialError`. + +```typescript +// EKS through exec: auth only, no Pod Identity or ECR. +const cluster: Kubernetes.Connection = { + endpoint: "https://ABC123.gr7.us-east-1.eks.amazonaws.com", + certificateAuthorityData: "LS0tLS1CRUdJTi...", + auth: { + kind: "exec", + command: "aws", + args: ["eks", "get-token", "--cluster-name", "prod", "--region", "us-east-1"], + env: { AWS_PROFILE: "prod" }, + }, +}; +``` + +:::caution[Credentials are persisted in state] +A `token` or client `key` is stored in state. Use a remote state store, or prefer `exec` and `kubeconfig`. +::: + +## Deploying to EKS + +`Kubernetes.providers()` handles `kubeconfig`, `token`, `client-cert`, +and `exec`. To deploy to EKS (`aws-eks`), add `AWS.providers()` next +to it. + +```typescript +// alchemy.run.ts +export default Alchemy.Stack( + "MyApp", + { + // local / kubeconfig / token / client-cert / exec clusters: + providers: Kubernetes.providers(), + // to also target an AWS.EKS.Cluster (auth kind "aws-eks"): + // providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { /* ... */ }), +); +``` + +If you forget `AWS.providers()`, an EKS deploy fails with: + +```text +No Kubernetes cluster adapter is registered for auth kind 'aws-eks'. +Add the provider layer that contributes it to the stack's providers — +e.g. 'aws-eks' ships with `AWS.providers()`; the built-in kinds +(kubeconfig, token, client-cert, exec) ship with +`Kubernetes.providers()`. Compose multiple provider layers with +`Layer.mergeAll(AWS.providers(), Kubernetes.providers())`. +``` + +## Where next + +- [Local clusters](/kubernetes/clusters/local) +- [EKS](/kubernetes/clusters/eks) +- [Cluster adapters](/kubernetes/guides/cluster-adapters) — add a cluster type of your own diff --git a/website/src/content/docs/kubernetes/clusters/eks.mdx b/website/src/content/docs/kubernetes/clusters/eks.mdx new file mode 100644 index 0000000000..f76283ae4c --- /dev/null +++ b/website/src/content/docs/kubernetes/clusters/eks.mdx @@ -0,0 +1,133 @@ +--- +title: EKS +description: Deploy Kubernetes workloads to an AWS EKS cluster with IAM auth, a Pod Identity role per workload, and an ECR repository so you can deploy your own code. +--- + +Pass an `AWS.EKS.Cluster` as `cluster` to get IAM auth, a Pod +Identity role per workload, and an ECR repository for your images. To +create one, see the AWS tab's [EKS page](/aws/compute/eks). + +## Targeting an EKS cluster + +Add `AWS.providers()` next to `Kubernetes.providers()`. +Pass the cluster resource, or an `aws-eks` connection for a cluster +created elsewhere. + +```typescript +// alchemy.run.ts +export default Alchemy.Stack( + "MyApp", + { + providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), // both: EKS needs AWS.providers() + state: AWS.state(), + }, + Effect.gen(function* () { + // (a) the cluster resource — see /aws/compute/eks for the Network + Cluster props + const network = yield* AWS.EC2.Network("Network", { /* see the AWS tab */ }); + const cluster = yield* AWS.EKS.Cluster("Cluster", { + compute: "auto", + resourcesVpcConfig: { subnetIds: network.privateSubnetIds }, + }); + + // (b) a cluster created elsewhere: name + region is enough + const shared: Kubernetes.Connection = { + auth: { kind: "aws-eks", clusterName: "platform-prod", region: "eu-west-1" }, + }; + + yield* Kubernetes.Deployment("Api", { cluster, main: import.meta.url, port: 3000 }); + yield* Kubernetes.Job("Nightly", { cluster: shared, image: "ghcr.io/acme/report:v3", schedule: "0 3 * * *" }); + }), +); +``` + +Your AWS credentials need cluster API access plus IAM, EKS, and ECR +permissions. An `aws eks update-kubeconfig` context through +[`KubeConfig()`](/kubernetes/clusters/connecting#kubeconfig) only +authenticates, with no Pod Identity or ECR. + +## What EKS adds + +On EKS you also get: + +### Auth + +Your ambient AWS credentials sign you in, like `aws eks get-token`, +with no kubeconfig. If the cluster is gone or `DELETING`, the deploy +fails with `Kubernetes.ClusterNotFoundError`. + +### Identity + +Each workload gets an IAM role, an inline policy built from its +bindings, and a Pod Identity association to its ServiceAccount +([Pod Identity on EKS](/kubernetes/workloads/bindings#pod-identity-on-eks)). + +```typescript +Effect.gen(function* () { + const table = yield* AWS.DynamoDB.Table("Entries", { /* ... */ }); + + const api = yield* Kubernetes.Deployment( + "Api", + { + cluster, + main: import.meta.url, + port: 3000, + identity: { managedPolicyArns: ["arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess"] }, // extra managed policies on the pod's role + }, + Effect.gen(function* () { + const putItem = yield* AWS.DynamoDB.PutItem(table); // grants PutItem on the pod's role + return { fetch: /* ... */ }; + }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)), + ); + + // the role and association, once deployed: + api.identity; // { kind: "aws-pod-identity", roleArn, roleName, associationArn, associationId } | undefined +}); +``` + +### Registry + +Each workload gets a private ECR repository. `main` is bundled, +`context` is built, and `image:` is copied into it. Install Docker +where you deploy from. + +```typescript +const web = yield* Kubernetes.Deployment("Web", { + cluster, + image: "public.ecr.aws/nginx/nginx:1.27", // copied into the workload's ECR repo; nodes pull from there + port: 80, +}); +web.imageUri; // ".dkr.ecr..amazonaws.com/:" +web.registry; // { kind: "aws-ecr", repositoryName, repositoryUri } | undefined +``` + +### Load balancer defaults + +On Auto Mode, `LoadBalancer` Services get +`loadBalancerClass: eks.amazonaws.com/nlb` and an `internet-facing` +scheme. Your `serviceAnnotations` override either. + +```typescript +const api = yield* Kubernetes.Deployment("Api", { + cluster, + main: import.meta.url, + port: 3000, + serviceType: "LoadBalancer", // default + serviceAnnotations: { + // make it private instead of internet-facing: + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", + }, +}); +api.url; // "http://k8s-….elb.amazonaws.com:3000" once the NLB exists +``` + +## Destroy and a missing cluster + +If the cluster is gone or `DELETING`, `alchemy destroy` skips the +in-cluster objects but still removes the IAM role and ECR repository; +the next deploy re-creates the workloads. + +## Where next + +- [Deployments](/kubernetes/workloads/deployments) +- [Pod Identity on EKS](/kubernetes/workloads/bindings#pod-identity-on-eks) +- [EKS on the AWS tab](/aws/compute/eks) diff --git a/website/src/content/docs/kubernetes/clusters/local.mdx b/website/src/content/docs/kubernetes/clusters/local.mdx new file mode 100644 index 0000000000..eb7a7107a1 --- /dev/null +++ b/website/src/content/docs/kubernetes/clusters/local.mdx @@ -0,0 +1,138 @@ +--- +title: Local clusters +description: Deploy Kubernetes workloads to Docker Desktop, OrbStack, kind, k3s, or minikube, reach a Service from your laptop, and use images you build locally. +--- + +Connect with +[`KubeConfig()`](/kubernetes/clusters/connecting#kubeconfig). +Everything works except `main:` and `context:`; use `image:`. + +## Which local cluster + +Docker Desktop (`docker-desktop`), OrbStack (`orbstack`), and k3s/k3d +(`default` / `k3d-dev`) give `LoadBalancer` Services an address, so +`api.url` works. kind (`kind-dev`) and minikube (`minikube`) don't: a +default Deployment waits about 3 minutes and returns +`url: undefined`, so set `serviceType: "ClusterIP"` there. + +```typescript +Effect.gen(function* () { + // Pin the context so a teammate's `current-context` never retargets a deploy. + const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); + // kind: Kubernetes.KubeConfig({ context: "kind-dev" }) + // OrbStack: Kubernetes.KubeConfig({ context: "orbstack" }) + + const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + // default LoadBalancer → url is http://localhost:8080 on Docker Desktop + }); + return { url: api.url }; +}); +``` + +## Reaching a Service + +`LoadBalancer` (the default) gives you `api.url` where supported. +`NodePort` works everywhere, but you must look up the assigned port +yourself. On kind and minikube use `ClusterIP` and port-forward +([Service types](/kubernetes/guides/exposing-services#service-types)). + +```typescript +Effect.gen(function* () { + const cluster = Kubernetes.KubeConfig({ context: "kind-dev" }); + + const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + serviceType: "ClusterIP", // no LB on kind: skip the 3-minute wait, url stays undefined + }); + + // port-forward to api.serviceName in api.namespace: + return { service: api.serviceName, namespace: api.namespace }; +}); +``` + +```sh +kubectl port-forward -n default svc/myapp-api-dev-alex-k3m7p2q6r4s5t2v6 8080:8080 # svc/ +curl http://localhost:8080/ +``` + +Port-forward to `api.serviceName`: unless you pass `name`, the +Service name is generated. + +## Limits without a registry + +`main:` and `context:` fail on kubeconfig clusters; use `image:`. +Bindings are [env only](/kubernetes/workloads/bindings#env-only-bindings). + +## Using your own images + +Build the image, make sure the node can pull it, and pass the +reference as `image:`. + +### Docker Desktop and OrbStack + +An image you build with `Docker.Image` is already on the node. +Rebuilding it rolls the Deployment. + +```typescript +// alchemy.run.ts +export default Alchemy.Stack( + "MyApp", + { + providers: Layer.mergeAll(Docker.providers(), Kubernetes.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); + + // docker build ./api -t my-api:dev, through your active Docker context + const image = yield* Docker.Image("ApiImage", { + name: "my-api", + tag: "dev", + build: { context: "./api" }, + }); + + const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: image.imageRef, // "my-api:dev" — already in the node's image store + port: 8080, + // use a real tag, not :latest + }); + return { url: api.url }; + }), +); +``` + +Use a real tag; `:latest` fails with `ErrImagePull`. + +### kind, minikube, and k3d + +Load the image into the cluster before you deploy, and again after +every rebuild. + +```sh +kind load docker-image my-api:dev --name dev +minikube image load my-api:dev +k3d image import my-api:dev -c dev +``` + +### A local registry + +Or run a registry container, push to it, and pass the +`localhost:5001/...` reference as `image:`. + +```sh +docker run -d -p 5001:5000 --name registry registry:2 +docker tag my-api:dev localhost:5001/my-api:dev +docker push localhost:5001/my-api:dev +``` + +## Where next + +- [EKS](/kubernetes/clusters/eks) — `main:` with no registry setup +- [Deployments](/kubernetes/workloads/deployments) +- [Exposing services](/kubernetes/guides/exposing-services) diff --git a/website/src/content/docs/kubernetes/guides/cluster-adapters.mdx b/website/src/content/docs/kubernetes/guides/cluster-adapters.mdx new file mode 100644 index 0000000000..fc22e522e9 --- /dev/null +++ b/website/src/content/docs/kubernetes/guides/cluster-adapters.mdx @@ -0,0 +1,333 @@ +--- +title: Cluster adapters +description: Write a cluster adapter so Kubernetes workloads can target a new platform such as GKE or AKS. +--- + +A [`Connection`](/kubernetes/clusters/connecting#what-cluster-accepts)'s +`auth.kind` +[picks](/kubernetes/clusters/connecting#deploying-to-eks) +the `ClusterAdapterService` that talks to the API server and, +optionally, gives workloads an identity and an image registry. Write +one to support a new platform (GKE, AKS, a corporate OIDC proxy). + +## The interface + +```typescript +import type * as Kubernetes from "alchemy/Kubernetes"; + +interface ClusterAdapterService { + readonly kind: "Kubernetes.ClusterAdapter"; + + // REQUIRED: resolve endpoint/CA and mint headers per request. + readonly connect: ( + connection: Kubernetes.Connection, + ) => Effect.Effect; + + // OPTIONAL: workload identity (EKS Pod Identity). + readonly identity?: { + readonly reconcile: (o: WorkloadIdentityReconcileOptions) => Effect.Effect; + readonly delete: (o: WorkloadIdentityDeleteOptions) => Effect.Effect; + }; + + // OPTIONAL: managed image registry (ECR). + readonly registry?: { + readonly resolve: (o: ImageRegistryResolveOptions) => Effect.Effect; + readonly hash: (o: ImageRegistryHashOptions) => Effect.Effect; + readonly delete: (o: ImageRegistryDeleteOptions) => Effect.Effect; + }; + + // OPTIONAL: platform-specific entry files for `main` workloads. + readonly bootstrap?: { + readonly server?: (handler: string) => (importPath: string) => string; + readonly job?: (handler: string) => (importPath: string) => string; + }; + + // OPTIONAL: defaults for `serviceType: "LoadBalancer"` Services. + readonly loadBalancerDefaults?: (o: { connection: Kubernetes.Connection }) => Effect.Effect< + { loadBalancerClass?: string | undefined; annotations?: Record }, + any, + AdapterLifecycleServices + >; +} +``` + +Implement `connect`. Its `ClusterTransport.headers` Effect runs on +every API request, so mint short-lived tokens there. Fail with +`ClusterNotFoundError` only when the cluster is gone for good; any +other error is treated as transient. + +Add `identity` to give workloads a cloud identity: it receives the +workload's bindings and returns +`{ env, serviceAccountAnnotations?, state }`. Without it, bindings are +[env-only](/kubernetes/workloads/bindings#env-only-bindings) and +`policyStatements` fail the deploy. Add `registry` to support `main` +and `context`: `resolve` turns any image source into +`{ imageUri, codeHash, state }`. Without it, only `image` works, +[verbatim](/kubernetes/workloads/image-sources#registry-requirement). +`bootstrap` replaces the generated entry file for `main` workloads. +`loadBalancerDefaults` go under the user's `serviceAnnotations` +([Service types](/kubernetes/guides/exposing-services#service-types)). + +`AdapterLifecycleServices` (`InstanceId | Stack | Stage`) are +per-resource: don't capture them when you build the layer. Capture +everything else once with `Effect.context` inside `Layer.effect`. An +adapter with only `connect` supports every resource; you lose only +`main`/`context` images and cloud-grant bindings. + +## Registering an auth kind + +Add your kind to `AuthRegistry` in `alchemy/Kubernetes/Connection` +with module augmentation. `{ auth: { kind: "gke", … } }` then +type-checks on every workload's `cluster` prop. + +### Augment `AuthRegistry` + +Put only what identifies the cluster in the descriptor, never +credentials: it is +[saved on every workload](/kubernetes/clusters/connecting#what-cluster-accepts), +and changing it replaces the workload. + +```typescript +// In your package: +declare module "alchemy/Kubernetes/Connection" { + interface AuthRegistry { + /** Authenticate against a GKE cluster with gke-gcloud-auth-plugin. */ + gke: { + project: string; + location: string; // region or zone + clusterName: string; + }; + } +} +``` + +### Augment the state registries + +Only if you add `identity` or `registry`. Copy the EKS block: + +```typescript +// What the built-in EKS adapter declares +declare module "../../Kubernetes/ClusterAdapter.ts" { + interface IdentityStateRegistry { + "aws-pod-identity": { + roleArn: string; + roleName: string; + associationArn: string; + associationId: string; + }; + } + interface RegistryStateRegistry { + "aws-ecr": { + repositoryName: string; + repositoryUri: string; + }; + } + interface WorkloadIdentityOptions { + managedPolicyArns?: string[]; + } + interface WorkloadBindingContract { + policyStatements?: PolicyStatement[]; + } + interface WorkloadServicesRegistry { + aws: Credentials | Region | AWSEnvironment; + } +} +``` + +### Build the layer + +Build a `Layer.effect` for `Kubernetes.ClusterAdapter("gke")`. +Return an object that `satisfies ClusterAdapterService`, and die if +`connection.auth.kind` isn't yours. + +### Provide it into the Stack + +Merge the layer beside `Kubernetes.providers()`: + +```typescript +export default Alchemy.Stack( + "my-app", + { + providers: Layer.mergeAll(Kubernetes.providers(), GkeKubernetesAdapter()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* Kubernetes.Deployment("Api", { + cluster: { + auth: { + kind: "gke", + project: "acme-prod", + location: "us-central1", + clusterName: "main", + }, + }, + image: "ghcr.io/acme/api:v3", + port: 8080, + }); + return { url: api.url }; + }), +); +``` + +To let users pass a cluster resource as `cluster` directly, give it +a `connection` attribute, as +[`AWS.EKS.Cluster`](/kubernetes/clusters/eks#what-eks-adds) +does. + +:::caution +`auth` is saved in plain state. Never put a secret in an `AuthRegistry` member. +::: + +## A GKE adapter sketch + +An auth-only adapter. It looks up the endpoint and CA from the GKE +API, then hands token minting to the built-in +[`exec` adapter](/kubernetes/clusters/connecting#token-client-certificate-and-exec) +with `gke-gcloud-auth-plugin`: + +```typescript +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +declare module "alchemy/Kubernetes/Connection" { + interface AuthRegistry { + gke: { project: string; location: string; clusterName: string }; + } +} + +/** An access token for the GKE API from the gcloud CLI you're already logged into. */ +const gcloudAccessToken = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { exitCode, stdout, stderr } = yield* ChildProcess.make( + "gcloud", + ["auth", "print-access-token"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: false, + extendEnv: true, + }, + ).pipe( + spawner.spawn, + Effect.flatMap((child) => + Effect.all( + { + exitCode: child.exitCode, + stdout: child.stdout.pipe(Stream.decodeText, Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText, Stream.mkString), + }, + { concurrency: "unbounded" }, + ), + ), + Effect.scoped, + ); + if (exitCode !== 0) { + return yield* Effect.fail( + new Error( + `gcloud auth print-access-token exited with ${String(exitCode)}: ${stderr.trim()}`, + ), + ); + } + return stdout.trim(); +}); + +/** Look up the cluster in the GKE API; a 404 means it is gone. */ +const describeGkeCluster = Effect.fn(function* (auth: { + project: string; + location: string; + clusterName: string; +}) { + const http = yield* HttpClient.HttpClient; + const accessToken = yield* gcloudAccessToken; + const res = yield* http.execute( + HttpClientRequest.get( + `https://container.googleapis.com/v1/projects/${auth.project}/locations/${auth.location}/clusters/${auth.clusterName}`, + ).pipe(HttpClientRequest.bearerToken(accessToken)), + ); + if (res.status === 404) { + return yield* Effect.fail( + new Kubernetes.ClusterNotFoundError({ + message: `GKE cluster '${auth.clusterName}' no longer exists`, + }), + ); + } + const body = (yield* res.json) as { + endpoint?: string; + masterAuth?: { clusterCaCertificate?: string }; + }; + return { + endpoint: body.endpoint, + certificateAuthorityData: body.masterAuth?.clusterCaCertificate, + }; +}); + +export const GkeKubernetesAdapter = () => + Layer.effect( + Kubernetes.ClusterAdapter("gke"), + Effect.gen(function* () { + // Capture dependencies once when the layer is built. + const context = yield* Effect.context< + HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner + >(); + + const connect = Effect.fn(function* (connection: Kubernetes.Connection) { + if (connection.auth.kind !== "gke") { + return yield* Effect.die( + new Error(`gke adapter received auth kind '${connection.auth.kind}'`), + ); + } + const auth = connection.auth; + + // 1. Look up endpoint and CA unless the connection already has them. + let { endpoint, certificateAuthorityData } = connection; + if (!endpoint || !certificateAuthorityData) { + const described = yield* describeGkeCluster(auth).pipe( + Effect.provideContext(context), + ); + endpoint = described.endpoint ? `https://${described.endpoint}` : undefined; + certificateAuthorityData = described.certificateAuthorityData; + if (!endpoint || !certificateAuthorityData) { + return yield* Effect.fail( + new Error( + `GKE cluster '${auth.clusterName}' has no endpoint yet (still creating?)`, + ), + ); + } + } + + // 2. Let the built-in `exec` adapter mint tokens per request + // (cached until expirationTimestamp). + const exec = yield* Kubernetes.findClusterAdapter("exec"); + return yield* exec.connect({ + endpoint, + certificateAuthorityData, + auth: { kind: "exec", command: "gke-gcloud-auth-plugin" }, + }); + }); + + return { + kind: "Kubernetes.ClusterAdapter" as const, + connect, + } satisfies Kubernetes.ClusterAdapterService; + }), + ); +``` + +You now get every `Kubernetes.*` resource on GKE with `image:` +sources and env-only bindings. Add `identity` (Workload Identity +Federation via `serviceAccountAnnotations`) and `registry` (Artifact +Registry) to unlock `main:` and cloud-grant bindings without changing +any workload. + +## Where next + +- [EKS](/kubernetes/clusters/eks) +- [Connecting to a cluster](/kubernetes/clusters/connecting) +- [Custom providers](/infrastructure-as-code/custom-provider) diff --git a/website/src/content/docs/kubernetes/guides/exposing-services.mdx b/website/src/content/docs/kubernetes/guides/exposing-services.mdx new file mode 100644 index 0000000000..09ebf4c220 --- /dev/null +++ b/website/src/content/docs/kubernetes/guides/exposing-services.mdx @@ -0,0 +1,220 @@ +--- +title: Exposing services +description: Put a Kubernetes Deployment behind a cloud load balancer, an Ingress controller, or a Cloudflare Tunnel. +--- + +Every `Kubernetes.Deployment` gets a Service. Choose what fronts it: a +cloud load balancer, an Ingress controller, or a Cloudflare Tunnel. + +## Service types + +Set `serviceType` to `LoadBalancer` (default, a cloud load balancer and +a `url`), `ClusterIP` (in-cluster only, no `url`), or `NodePort`. See +[Exposing it: serviceType and url](/kubernetes/workloads/deployments#exposing-it-servicetype-and-url). + +On `LoadBalancer`, your `serviceAnnotations` override the cluster's +[defaults](/kubernetes/clusters/eks#what-eks-adds) key by key: + +```typescript +// One public Deployment. On EKS you get these defaults: +// metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-scheme"] = "internet-facing" +// spec.loadBalancerClass = "eks.amazonaws.com/nlb" (Auto Mode clusters) +// Your serviceAnnotations override them key by key. +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + serviceType: "LoadBalancer", + serviceAnnotations: { + // keep the NLB private to the VPC instead of the internet-facing default + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", + // route straight to pod IPs instead of node ports + "service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip", + }, +}); + +api.url; // "http://:8080" +``` + +Behind an Ingress or a Tunnel, use `ClusterIP`: + +```typescript +// Internal only: no cloud LB, no url. Reach it inside the cluster at +// http://..svc.cluster.local: +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + serviceType: "ClusterIP", +}); +``` + +## Ingress + +Install one Ingress controller with `Kubernetes.HelmChart` and put +many `ClusterIP` Services behind it. On EKS Auto Mode, set the load +balancer class and scheme in `values`: + +```typescript +const ingressNginx = yield* Kubernetes.HelmChart("IngressNginx", { + cluster, + chart: "ingress-nginx", + repo: "https://kubernetes.github.io/ingress-nginx", + version: "4.13.9", // pin the version + releaseName: "ingress-nginx", // names the objects ingress-nginx-* + namespace: "ingress-nginx", + createNamespace: true, + values: { + controller: { + // EKS Auto Mode only: the chart's Service gets no EKS defaults + service: { + loadBalancerClass: "eks.amazonaws.com/nlb", + annotations: { + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internet-facing", + }, + }, + }, + }, +}); +``` + +Add the route, with the Deployment's `serviceName` and `port` as the +backend: + +```typescript +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + serviceType: "ClusterIP", // the Ingress controller is the only public entry point +}); + +yield* Kubernetes.Manifest("ApiIngress", { + cluster, + manifest: { + apiVersion: "networking.k8s.io/v1", + kind: "Ingress", + metadata: { name: "api", namespace: api.namespace }, + spec: { + ingressClassName: "nginx", + rules: [ + { + host: "api.example.com", + http: { + paths: [ + { + path: "/", + pathType: "Prefix", + backend: { + service: { + name: api.serviceName, // Output: the Ingress deploys after the Deployment + port: { number: api.port }, + }, + }, + }, + ], + }, + }, + ], + }, + }, +}); +``` + +Point DNS at the controller's Service: + +```sh +kubectl -n ingress-nginx get svc ingress-nginx-controller +``` + +## Cloudflare Tunnel + +A [Tunnel](/cloudflare/networking/tunnel) needs no inbound path: +`cloudflared` runs as a pod and dials out. Build the upstream URL with +[`Output.interpolate`](/infrastructure-as-code/outputs#interpolate) +and store the token in a +[Secret](/kubernetes/objects/manifests#secrets) as a plain string with +`Output.map(tunnel.token, Redacted.value)`. + +```typescript +import * as Output from "alchemy/Output"; +import * as Redacted from "effect/Redacted"; + +// Cloudflare side. See /cloudflare/networking/tunnel for the full setup. +const tunnel = yield* Cloudflare.Tunnel.Tunnel("ClusterTunnel"); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + serviceType: "ClusterIP", // nothing public: the tunnel dials out +}); + +// Route a hostname to the in-cluster Service DNS name. serviceName, namespace +// and port are Outputs, so build the string with Output.interpolate; a plain +// template literal won't resolve. +yield* Cloudflare.Tunnel.Configuration("ClusterIngress", { + tunnelId: tunnel.tunnelId, + ingress: [ + { + hostname: "api.example.com", + service: Output.interpolate`http://${api.serviceName}.${api.namespace}.svc.cluster.local:${api.port}`, + }, + ], +}); + +// Kubernetes side: the token as a Secret, the connector as a Deployment. +const tunnelSecret = yield* Kubernetes.Manifest("TunnelToken", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Secret", + metadata: { name: "cloudflared-token", namespace: "default" }, + stringData: { + // unwrap the token; it is saved in state, so use a remote state store + TUNNEL_TOKEN: Output.map(tunnel.token, Redacted.value), + }, + }, +}); + +yield* Kubernetes.Manifest("Cloudflared", { + cluster, + manifest: { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: "cloudflared", namespace: "default" }, + spec: { + replicas: 2, // two connectors: no single point of failure + selector: { matchLabels: { app: "cloudflared" } }, + template: { + metadata: { labels: { app: "cloudflared" } }, + spec: { + containers: [ + { + name: "cloudflared", + image: "cloudflare/cloudflared:2026.8.2", + args: ["tunnel", "--no-autoupdate", "run"], + envFrom: [{ secretRef: { name: tunnelSecret.name } }], // Output: deploys after the Secret + }, + ], + }, + }, + }, + }, +}); +``` + +Finish with a +[proxied CNAME](/cloudflare/networking/tunnel#point-dns-at-the-tunnel). +Add `Cloudflare.providers()` beside `Kubernetes.providers()` in the +Stack. + +:::caution +The unwrapped token shows in `alchemy plan` diffs and is saved in state, so use a remote state store. +::: + +## Where next + +- [How apply works](/kubernetes/guides/how-apply-works) +- [Helm charts](/kubernetes/objects/helm) +- [Tunnel](/cloudflare/networking/tunnel) diff --git a/website/src/content/docs/kubernetes/guides/how-apply-works.mdx b/website/src/content/docs/kubernetes/guides/how-apply-works.mdx new file mode 100644 index 0000000000..26785b92a9 --- /dev/null +++ b/website/src/content/docs/kubernetes/guides/how-apply-works.mdx @@ -0,0 +1,152 @@ +--- +title: How apply works +description: What an Alchemy deploy does to your Kubernetes objects and how to debug it with kubectl. +--- + +Every `Kubernetes.*` resource applies its objects with server-side +apply under the `alchemy` field manager, then deletes whatever +dropped out. + +## Server-side apply + +Every object goes through one request: + +``` +PATCH /apis/apps/v1/namespaces/default/deployments/api?fieldManager=alchemy&force=true +Content-Type: application/apply-patch+yaml +Accept: application/json + +{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": {...}, "spec": {...} } +``` + +If you see `Kind 'X' not found in API group 'Y'`, the +[CRD is not installed yet](/kubernetes/objects/manifests#custom-resources) +or the `apiVersion` is wrong. + +Alchemy owns every field it sends and leaves the rest alone. Remove a +key from a Manifest `spec` or from chart `values` and it is removed +from the object. `force=true` takes any field someone else owns; other +tools get a [conflict](#conflicts) instead. + +Objects in one resource apply in order: Namespace, CRD, +ServiceAccount, ConfigMap and Secret, Service, workloads, then +everything else. A chart's custom resources always find their CRD. If +you declare the same object twice, the last one wins. + +## Pruning and drift + +An object that drops out of a resource is deleted on the next deploy, +before anything is applied. That happens when a Helm +[`values`](/kubernetes/objects/helm#values) change stops rendering an +object, a [Job spec changes](/kubernetes/workloads/jobs#jobs), or +`createNamespace` flips to `false`. A `Manifest` never prunes; +changing its identity is a +[replacement](/kubernetes/objects/manifests#any-object). + +An out-of-band `kubectl scale` or `set image` is reverted the next +time the resource applies; fields Alchemy never sent survive. A +resource applies only when a prop or content hash changes: + +```typescript +// Deploy #1: applies spec.replicas = 3 under manager "alchemy". +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + replicas: 3, +}); + +// Someone runs: kubectl scale deploy/ --replicas=10 +// +// `alchemy deploy` with NO prop change: Api is a no-op and replicas stay at 10. +// `alchemy deploy` after ANY prop change (new image tag, a label, an env var): +// replicas go back to 3 and the field is taken back from kubectl. +// `alchemy deploy --force`: every resource applies even without a change; +// replicas go back to 3. +``` + +To fix changed fields, run [`alchemy deploy --force`](/cli/deploy). +If an object was deleted out of band, run `alchemy sync`. + +:::caution[HorizontalPodAutoscalers] +`Deployment` always applies `spec.replicas`. For an autoscaled workload, write the Deployment as a [`Manifest`](/kubernetes/objects/manifests#any-object) and leave out `spec.replicas`. +::: + +## Conflicts + +Other server-side appliers get a conflict when they touch a field +Alchemy owns: + +``` +$ kubectl apply --server-side -f deployment.yaml +error: Apply failed with 1 conflict: conflict with "alchemy": .spec.replicas +Please review the fields above--they currently have other managers. Here +are the ways you can resolve this warning: ... +``` + +Don't share fields. Let other tools write only fields Alchemy never +sends (an annotation, a field +[left out of a Manifest](/kubernetes/objects/manifests#any-object)). +`--force-conflicts` wins once; the next apply takes the field back. +Client-side `kubectl apply`, `edit`, and `scale` never conflict and +are reverted the same way. + +A Manifest that matches an existing object takes it over, owning the +fields it sends. + +## Delete order + +Objects in a resource are deleted in reverse apply order; an object +that is already gone counts as deleted. Alchemy doesn't wait, so a +Namespace can still be `Terminating` when +[`alchemy destroy`](/cli/destroy) returns. + +`Deployment` and `Job` then clean up their cloud-side resources +([EKS](/kubernetes/clusters/eks#destroy-and-a-missing-cluster)). +`HelmChart` with `includeCrds` deletes the CRDs last, which deletes +every custom resource of those kinds cluster-wide. + +Across the stack, deletes run in reverse dependency order. Only +Outputs create dependencies: a workload with `namespace: ns.name` is +deleted before the Namespace; one with a literal `namespace: "apps"` +may go in either order +([Namespaces](/kubernetes/objects/manifests#namespaces)). + +## Debugging + +A failed apply reads `PATCH responded : `. `422` +is a schema error, `404` means the kind doesn't exist on this cluster, +`403` means your identity lacks RBAC. + +### See who owns what + +```sh +kubectl -n default get deploy api -o yaml --show-managed-fields +``` + +Look for `manager: alchemy` with `operation: Apply`: those are the +fields Alchemy will take back. + +### See what Alchemy remembers + +```sh +alchemy state get --stack --stage --fqn Api +``` + +This lists the objects pruning and delete act on. + +### Reproduce a render + +```sh +helm template --repo --version \ + --namespace --include-crds --no-hooks -f values.yaml +``` + +This is the exact command Alchemy runs; +[hook-annotated objects](/kubernetes/objects/helm#what-helm-does-that-alchemy-doesnt) +are dropped. + +## Where next + +- [Cluster adapters](/kubernetes/guides/cluster-adapters) +- [HelmChart lifecycle](/kubernetes/objects/helm#lifecycle) +- [Manifests](/kubernetes/objects/manifests) diff --git a/website/src/content/docs/kubernetes/index.mdx b/website/src/content/docs/kubernetes/index.mdx new file mode 100644 index 0000000000..92a313cced --- /dev/null +++ b/website/src/content/docs/kubernetes/index.mdx @@ -0,0 +1,84 @@ +--- +title: Kubernetes +description: Deploy servers, jobs, manifests, and Helm charts to any Kubernetes cluster with Alchemy. +--- + +[`Kubernetes.Deployment`](/providers/kubernetes/deployment) runs a +server, `Kubernetes.Job` runs a container to completion, +`Kubernetes.Manifest` applies any object, and `Kubernetes.HelmChart` +installs a chart. Point each at a cluster with its +[`cluster` prop](/kubernetes/clusters/connecting#what-cluster-accepts). + +```typescript +// alchemy.run.ts — inside the Stack's Effect.gen body +const local = Kubernetes.KubeConfig({ context: "docker-desktop" }); // any context in your kubeconfig +// const cluster = yield* AWS.EKS.Cluster("Cluster", { compute: "auto", /* … */ }); // …or an EKS cluster + +const web = yield* Kubernetes.Deployment("Web", { + cluster: local, // swap this to target another cluster + image: "nginx:1.27", + port: 80, + replicas: 2, +}); + +return { url: web.url }; // the LoadBalancer hostname, once the cluster assigns one +``` + +## What each cluster supports + +Any cluster runs pre-built images. EKS also builds your own and gives +Pods AWS access; add `AWS.providers()` beside `Kubernetes.providers()`. + +| | Any other cluster | [EKS](/kubernetes/clusters/eks#what-eks-adds) | +| --- | --- | --- | +| Auth | Kubeconfig, [token, cert, or exec](/kubernetes/clusters/connecting#token-client-certificate-and-exec) | Your AWS credentials | +| `image:` | Used as-is | Copied to ECR | +| `main:` / `context:` | [Not available](/kubernetes/workloads/image-sources#registry-requirement) | Built and pushed to ECR | +| Bindings | [Env vars](/kubernetes/workloads/bindings#bind-a-resource) | Env vars + [Pod Identity](/kubernetes/workloads/bindings#pod-identity-on-eks) | + +:::note +Create the EKS cluster on the [AWS tab](/aws/compute/eks). +::: + +## Clusters + +- [Connecting](/kubernetes/clusters/connecting) +- [Local clusters](/kubernetes/clusters/local) +- [EKS](/kubernetes/clusters/eks) + +## Workloads + +- [Deployments](/kubernetes/workloads/deployments) +- [Jobs](/kubernetes/workloads/jobs) +- [Image sources](/kubernetes/workloads/image-sources) +- [Bindings](/kubernetes/workloads/bindings) +- [Pod template](/kubernetes/workloads/pod-template) + +## Objects + +- [Manifests](/kubernetes/objects/manifests) +- [Helm charts](/kubernetes/objects/helm) + +## Guides + +- [Exposing services](/kubernetes/guides/exposing-services) +- [How apply works](/kubernetes/guides/how-apply-works) +- [Cluster adapters](/kubernetes/guides/cluster-adapters) + +## What are you building? + +| Building | Use | +| --- | --- | +| A server from a pre-built image | [Deployment](/kubernetes/workloads/deployments#what-gets-created) with `image:` | +| An Effect server | [Deployment](/kubernetes/workloads/deployments#effect-servers) with `main:` (EKS only) | +| A one-off or scheduled task | [Job](/kubernetes/workloads/jobs#jobs) / [CronJob](/kubernetes/workloads/jobs#cronjobs) | +| Any other object | [Manifest](/kubernetes/objects/manifests#any-object) | +| An off-the-shelf chart | [HelmChart](/kubernetes/objects/helm#chart-sources) | +| AWS access from a Pod | [Pod Identity](/kubernetes/workloads/bindings#pod-identity-on-eks) | +| A public URL | [Exposing services](/kubernetes/guides/exposing-services#service-types) | + +## Where next + +- [Setup](/kubernetes/setup) +- [Tutorial](/kubernetes/tutorial/part-1) +- [Providers reference](/providers) diff --git a/website/src/content/docs/kubernetes/objects/helm.mdx b/website/src/content/docs/kubernetes/objects/helm.mdx new file mode 100644 index 0000000000..f6c584c4c3 --- /dev/null +++ b/website/src/content/docs/kubernetes/objects/helm.mdx @@ -0,0 +1,146 @@ +--- +title: Helm charts +description: Install a Helm chart from a repository, an OCI registry, or a local directory with Kubernetes.HelmChart. +--- + +[`Kubernetes.HelmChart`](/providers/kubernetes/helmchart) renders a +chart with the `helm` CLI on your machine and applies the objects like +a [Manifest](/kubernetes/objects/manifests#any-object). +[Install helm](/kubernetes/setup#install) first; set `HELM_BIN` to use +a specific binary. + +## Chart sources + +Set `chart` to a chart name with `repo`, an `oci://` reference, or a +local directory. Edits to a local chart show as an update in +`alchemy plan`. + +```typescript +// 1. Repository chart: no `helm repo add` needed. +const ingress = yield* Kubernetes.HelmChart("IngressNginx", { + cluster, + chart: "ingress-nginx", + repo: "https://kubernetes.github.io/ingress-nginx", + version: "4.11.2", // pin it + namespace: "ingress-nginx", + createNamespace: true, +}); + +// 2. OCI reference — no `repo`. +const karpenter = yield* Kubernetes.HelmChart("Karpenter", { + cluster, + chart: "oci://public.ecr.aws/karpenter/karpenter", + version: "1.0.6", + namespace: "kube-system", +}); + +// 3. Local chart directory: template edits show in `alchemy plan`. +const app = yield* Kubernetes.HelmChart("App", { + cluster, + chart: "./charts/app", + values: { image: { tag: "v1.2.3" } }, +}); + +ingress.releaseName; // generated from stack, stage, and id unless you set releaseName +ingress.objects; // KubernetesObjectRef[] — every applied object +``` + +Changing `releaseName` or `namespace` (default `"default"`) replaces +the release. Set `releaseName` when the chart names objects after +`.Release.Name` and you care about those names. + +## Values + +`values` has the same shape as `values.yaml`. Put +[outputs](/infrastructure-as-code/outputs) anywhere inside it and the +chart deploys after the resource they come from, like +[`namespace: ns.name`](/kubernetes/objects/manifests#namespaces). For +secrets use `Config.string`, never `Redacted` +([Secrets](/kubernetes/objects/manifests#secrets)). + +Pin `version`. Without it, an unrelated edit can pull a newer chart. + +```typescript +const podinfo = yield* Kubernetes.HelmChart("Podinfo", { + cluster, + chart: "podinfo", + repo: "https://stefanprodan.github.io/podinfo", + version: "6.14.1", // bump this to upgrade; never leave it off + namespace: ns.name, // deploys after the Namespace + releaseName: "podinfo", // .Release.Name; default derives from stack/stage/id + values: { + // same keys as values.yaml + replicaCount: 2, + ui: { color: "#34577c", message: "Deployed by Alchemy" }, + service: { type: "ClusterIP" }, + // Outputs are fine anywhere: e.g. backend: { url: api.url } + }, +}); +``` + +## Lifecycle + +Each deploy runs +`helm template --namespace --no-hooks` on +your machine, sets `metadata.namespace` on objects that omit it, +deletes objects that are no longer rendered, then +[applies](/kubernetes/guides/how-apply-works#server-side-apply) the +rest. If the template fails, you see helm's error output. + +`alchemy destroy` deletes every object in `objects`. With +`createNamespace: true` it also deletes the Namespace and everything +in it, so if other resources share the namespace, create it with a +[Namespace Manifest](/kubernetes/objects/manifests#namespaces) instead. +Any input change, or `alchemy deploy --force`, re-applies the whole +chart and [corrects drift](/kubernetes/guides/how-apply-works#pruning-and-drift). + +:::caution[CRDs are deleted on destroy] +By default `alchemy destroy` deletes the chart's CRDs and every +[custom resource](/kubernetes/objects/manifests#custom-resources) of +those kinds. Set `includeCrds: false` if other stacks depend on them. +::: + +```typescript +const podinfo = yield* Kubernetes.HelmChart("Podinfo", { + cluster, + chart: "podinfo", + repo: "https://stefanprodan.github.io/podinfo", + version: "6.14.1", + namespace: ns.name, + includeCrds: false, // don't apply or delete the chart's CRDs + // Disabling the Service removes it from the render. The next deploy + // deletes it first, then re-applies the rest and logs + // `Applying 2 objects from podinfo...`. + values: { service: { enabled: false } }, +}); +``` + +## What Helm does that Alchemy doesn't + +There is no Helm release in the cluster: `helm ls`, `helm rollback`, +and `helm test` don't see it, deploy doesn't `--wait`, and hooks +(`helm.sh/hook`) are neither applied nor run. Replace a hook with a +[`Kubernetes.Job`](/kubernetes/workloads/jobs#jobs). + +```typescript +// The chart's pre-upgrade migration hook won't run. Run the migration as +// a Job and reference its output from `values` so the chart deploys after it. +const migrate = yield* Kubernetes.Job("Migrate", { + cluster, + image: "ghcr.io/acme/migrator:v3", + namespace: ns.name, +}); + +const app = yield* Kubernetes.HelmChart("App", { + cluster, + chart: "./charts/app", + namespace: ns.name, + values: { migratedBy: migrate.jobName }, // chart deploys after the Job +}); +``` + +## Where next + +- [Exposing services](/kubernetes/guides/exposing-services) +- [How apply works](/kubernetes/guides/how-apply-works#pruning-and-drift) +- [`HelmChart` reference](/providers/kubernetes/helmchart) diff --git a/website/src/content/docs/kubernetes/objects/manifests.mdx b/website/src/content/docs/kubernetes/objects/manifests.mdx new file mode 100644 index 0000000000..3894a3acc4 --- /dev/null +++ b/website/src/content/docs/kubernetes/objects/manifests.mdx @@ -0,0 +1,216 @@ +--- +title: Manifests +description: Apply any Kubernetes object, including Namespaces, ConfigMaps, Secrets, and custom resources, with Kubernetes.Manifest. +--- + +Use [`Kubernetes.Manifest`](/providers/kubernetes/manifest) for any +object that isn't a `Deployment` or a `Job`. Write it exactly as you +would in YAML. + +## Any object + +```typescript +// Inside the Stack's Effect.gen body; `cluster` is a +// Kubernetes.KubeConfig(...) or a managed cluster resource. +const redis = yield* Kubernetes.Manifest("Redis", { + cluster, + manifest: { + // Exactly what you'd write in YAML, as an object literal. + apiVersion: "apps/v1", + kind: "StatefulSet", + metadata: { name: "redis", namespace: "default" }, // name is REQUIRED + spec: { + serviceName: "redis", + replicas: 1, + selector: { matchLabels: { app: "redis" } }, + template: { + metadata: { labels: { app: "redis" } }, + spec: { containers: [{ name: "redis", image: "redis:7" }] }, + }, + }, + }, +}); + +redis.ref; // { apiVersion: "apps/v1", kind: "StatefulSet", name: "redis", namespace: "default" } +redis.uid; // server-assigned UID (string | undefined) +``` + +Set `metadata.name`; `generateName` isn't supported. Changing +`apiVersion`, `kind`, `metadata.name`, or `metadata.namespace` replaces +the object. `alchemy destroy` deletes it. + +:::caution[Existing objects are taken over] +If the cluster already has an object with this name, Alchemy takes it +over and `alchemy destroy` deletes it. See +[conflicts](/kubernetes/guides/how-apply-works#conflicts). +::: + +Add the StatefulSet's headless Service as a second Manifest. + +```typescript +const redisService = yield* Kubernetes.Manifest("RedisService", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Service", + metadata: { name: "redis", namespace: "default" }, + spec: { + clusterIP: "None", // headless: one DNS entry per pod + selector: { app: "redis" }, + ports: [{ port: 6379 }], + }, + }, +}); +``` + +## Namespaces + +Create a Namespace as a Manifest. A `Deployment`, `Job`, or namespaced +Manifest needs its namespace to exist before it deploys, and a +namespaced Manifest must set `metadata.namespace`. + +To deploy something after the Namespace, reference `ns.name` in it. A +plain `"apps"` string doesn't order them. If no field needs the value, +put it in a label. + +```typescript +const ns = yield* Kubernetes.Manifest("AppsNamespace", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Namespace", // cluster-scoped: no metadata.namespace + metadata: { + name: "apps", + labels: { "app.kubernetes.io/part-of": "my-app" }, + }, + }, +}); + +ns.namespace; // undefined — cluster-scoped kinds have no namespace +ns.name; // "apps" + +const config = yield* Kubernetes.Manifest("AppConfig", { + cluster, + manifest: { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: "app-config", namespace: ns.name }, // deploys after the Namespace + data: { LOG_LEVEL: "info" }, + }, +}); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:1.4.0", + namespace: ns.name, // deploys after the Namespace exists; destroyed before it + env: { APP_CONFIG: config.name }, // any attribute orders the Deployment after the ConfigMap +}); +``` + +Deleting a Namespace deletes everything inside it. + +## Custom resources + +Reference the CRD's output from the custom resource so the CRD applies +first; otherwise the deploy fails with a 404. + +```typescript +const widgetCrd = yield* Kubernetes.Manifest("WidgetCrd", { + cluster, + manifest: { + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { name: "widgets.example.com" }, // cluster-scoped + spec: { + group: "example.com", + names: { kind: "Widget", plural: "widgets", singular: "widget" }, + scope: "Namespaced", + versions: [ + { + name: "v1", + served: true, + storage: true, + schema: { + openAPIV3Schema: { + type: "object", + properties: { + spec: { + type: "object", + properties: { size: { type: "integer" } }, + }, + }, + }, + }, + }, + ], + }, + }, +}); + +const widget = yield* Kubernetes.Manifest("Widget", { + cluster, + manifest: { + apiVersion: "example.com/v1", // any kind the cluster serves works + kind: "Widget", + metadata: { + name: "demo", + namespace: ns.name, + // Reference the CRD so this applies after it + // (and is removed before it on destroy). + labels: { "example.com/definition": widgetCrd.name }, + }, + spec: { size: 3 }, + }, +}); +``` + +Deleting a CRD deletes every custom resource of that kind in the +cluster. If a custom resource hits a 404 right after its CRD is +created, run `alchemy deploy` again. Install operators with a +[Helm chart](/kubernetes/objects/helm#values). + +## Secrets + +Write a `Secret` with `stringData` and read each value from your +environment with `Config.string`. Don't use `Config.redacted` or +`Redacted.make` inside `manifest` or Helm `values`: the cluster would +receive the text ``. + +```typescript +import * as Config from "effect/Config"; + +const dbSecret = yield* Kubernetes.Manifest("DbSecret", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Secret", + metadata: { name: "db-credentials", namespace: ns.name }, + type: "Opaque", + // stringData: Kubernetes base64-encodes it into `data` for you. + stringData: { + // Read from your environment at deploy time; don't paste the value. + // Use Config.string, not Config.redacted. + DB_PASSWORD: Config.string("DB_PASSWORD"), + }, + }, +}); + +// Reference `dbSecret.name` from the workload so the Secret is applied first. +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:1.4.0", + namespace: ns.name, + env: { DB_SECRET_NAME: dbSecret.name }, +}); +``` + +The secret values are stored in plaintext in your state, so use a +remote [state store](/state-store). To mount the Secret with `envFrom` +or a volume, see +[container-level fields](/kubernetes/workloads/pod-template#container-level-fields). + +## Where next + +- [Helm charts](/kubernetes/objects/helm) +- [How apply works](/kubernetes/guides/how-apply-works#server-side-apply) +- [`Manifest` reference](/providers/kubernetes/manifest) diff --git a/website/src/content/docs/kubernetes/setup.mdx b/website/src/content/docs/kubernetes/setup.mdx new file mode 100644 index 0000000000..cde250007a --- /dev/null +++ b/website/src/content/docs/kubernetes/setup.mdx @@ -0,0 +1,205 @@ +--- +title: Setup +description: Install Alchemy, connect it to a Kubernetes cluster, and plan your first manifest. +--- + +import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; +import { effectVersion } from "../../../versions"; +import Terminal from "../../../components/Terminal.astro"; + +export const pkgs = `"alchemy@latest" "effect@${effectVersion}" "@effect/platform-bun@${effectVersion}" "@effect/platform-node@${effectVersion}"`; + +You need [Bun](https://bun.sh) or Node.js 22+ and a cluster `kubectl` +can reach. Alchemy uses the same kubeconfig as `kubectl`; there is +nothing to log in to. + +## Create a project directory + + + + + + + + + + + + + + + + +## Install + + + + + + + + + + + + + + + + +To deploy [Helm charts](/providers/kubernetes/helmchart), install +`helm` (on `PATH`, or set `HELM_BIN`). To deploy to +[EKS](/kubernetes/clusters/eks#what-eks-adds), install Docker. + +```sh +brew install helm # macOS; other platforms: https://helm.sh/docs/intro/install/ +``` + +## Pick a cluster + +Any cluster `kubectl` can reach works. Use pre-built images (`image:`) +everywhere except EKS, which can also build your own. + + + + +The easiest is Docker Desktop (Settings → Kubernetes → Enable; context +`docker-desktop`) or OrbStack (context `orbstack`). `kind` and `k3s` +also work ([compare them](/kubernetes/clusters/local#which-local-cluster)). + +```sh +kubectl config current-context # e.g. docker-desktop +kubectl get nodes # one Ready node +``` + + + + +Pass an `AWS.EKS.Cluster` as `cluster` to get image builds and Pod +Identity; create it on the [AWS tab](/aws/compute/eks). A context from +`aws eks update-kubeconfig` gives auth only, no image builds or Pod +Identity. + +```typescript +// an EKS cluster resource: image builds, ECR, and Pod Identity +const cluster = yield* AWS.EKS.Cluster("Cluster", { + compute: "auto", + resourcesVpcConfig: { subnetIds: network.privateSubnetIds }, +}); + +// auth only: an existing context from `aws eks update-kubeconfig` +const existing = Kubernetes.KubeConfig({ context: "arn:aws:eks:us-east-1:123456789012:cluster/prod" }); +``` + + + + +Create a context with your provider's CLI +(`gcloud container clusters get-credentials …`, +`az aks get-credentials …`), then pass its name to +`Kubernetes.KubeConfig` ([kubeconfig](/kubernetes/clusters/connecting#kubeconfig)). + +```typescript +const gke = Kubernetes.KubeConfig({ context: "gke_my-proj_us-central1_prod" }); +``` + + + + +## Create alchemy.run.ts + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyApp", + { + providers: Kubernetes.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + // resources go here + }), +); +``` + +To deploy to EKS, add `AWS.providers()` as well: + +```diff lang="typescript" +import * as Alchemy from "alchemy"; ++import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "MyApp", + { +- providers: Kubernetes.providers(), ++ providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), + state: Alchemy.localState(), + }, +``` + +## Verify + +Add a [Namespace](/kubernetes/objects/manifests#namespaces) manifest +and plan it. + +```diff lang="typescript" +Effect.gen(function* () { ++ const cluster = Kubernetes.KubeConfig(); // your current context; pass { context: "..." } for another ++ ++ const ns = yield* Kubernetes.Manifest("DemoNamespace", { ++ cluster, ++ manifest: { ++ apiVersion: "v1", ++ kind: "Namespace", ++ metadata: { name: "demo" }, ++ }, ++ }); ++ ++ return { namespace: ns.name }; +}), +``` + + + + + + + + + + + + + + + + + + +A wrong context fails on `deploy`, not on `plan`. + +:::tip[Round-trip it] + +```sh +bun alchemy deploy +kubectl get namespace demo # STATUS Active +bun alchemy destroy +``` + +Then remove the `DemoNamespace` block before you start the tutorial. +::: + +## Where next + +- [Tutorial part 1](/kubernetes/tutorial/part-1) +- [Connecting to clusters](/kubernetes/clusters/connecting) +- [State Store](/state-store) diff --git a/website/src/content/docs/kubernetes/tutorial/part-1.mdx b/website/src/content/docs/kubernetes/tutorial/part-1.mdx new file mode 100644 index 0000000000..dca0c287cd --- /dev/null +++ b/website/src/content/docs/kubernetes/tutorial/part-1.mdx @@ -0,0 +1,242 @@ +--- +title: "Part 1: Your First Deployment" +description: Deploy a pre-built image to your local cluster with Kubernetes.Deployment, reach it, scale it, and tear it down. +sidebar: + order: 1 +--- + +import Terminal from "../../../../components/Terminal.astro"; +import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; + +Deploy a pre-built image to your local cluster, reach it from your +laptop, scale it, and tear it down. + +## Prerequisites + +- [Alchemy installed](/kubernetes/setup#install) +- A [local cluster](/kubernetes/setup#pick-a-cluster) in + `kubectl config get-contexts` + +Replace `docker-desktop` with your context name. On kind or minikube, +also set `serviceType` (see +[Choose how to reach it](#choose-how-to-reach-it)). + +## Create a project + +Keep using the Setup project, or start a new one and +[install Alchemy](/kubernetes/setup#install): + + + + + + + + + + + + + + + + +## Create the Stack + +Start with an empty Stack: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyApp", + { + providers: Kubernetes.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + // resources go here + }), +); +``` + +## Point at your cluster + +```diff lang="typescript" +Effect.gen(function* () { ++ const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); +}), +``` + +[`KubeConfig`](/kubernetes/clusters/connecting#kubeconfig) connects to +any cluster in your kubeconfig. + +## Add a Deployment + +```diff lang="typescript" +Effect.gen(function* () { + const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); + ++ const echo = yield* Kubernetes.Deployment("Echo", { ++ cluster, ++ image: "mendhak/http-https-echo:33", ++ port: 8080, ++ }); +}), +``` + +This creates a Deployment, a Service, and a +[ServiceAccount](/kubernetes/workloads/deployments#what-gets-created). +Use a pre-built image; local clusters +[can't build images](/kubernetes/workloads/image-sources#registry-requirement). + +## Choose how to reach it + +```diff lang="typescript" +const echo = yield* Kubernetes.Deployment("Echo", { + cluster, + image: "mendhak/http-https-echo:33", + port: 8080, ++ serviceType: "LoadBalancer", +}); +``` + +`LoadBalancer` is the default and gives the Deployment a +[`url`](/kubernetes/workloads/deployments#exposing-it-servicetype-and-url). + +:::note[No LoadBalancer on your cluster?] +On kind or minikube, set `serviceType: "NodePort"` and use +`kubectl port-forward` instead of `url` +([Reaching a service](/kubernetes/clusters/local#reaching-a-service)). +::: + +## Return Stack outputs + +```diff lang="typescript" +const echo = yield* Kubernetes.Deployment("Echo", { + cluster, + image: "mendhak/http-https-echo:33", + port: 8080, + serviceType: "LoadBalancer", +}); + ++return { ++ url: echo.url, ++ deployment: echo.deploymentName, ++}; +``` + +`deploymentName` is the name you pass to `kubectl`. + +## Deploy + + + + + + + + + + + + + + + + + + +## Try it out + +```sh +curl http://localhost:8080 +``` + +The pod's hostname is under `os.hostname`. List the objects Alchemy +created: + +```sh +kubectl get deployment,service,serviceaccount \ + -l app.kubernetes.io/name=myapp-echo-dev-alex-k3m7p2q6r4s5t2v6 +``` + +## Scale it + +```diff lang="typescript" +const echo = yield* Kubernetes.Deployment("Echo", { + cluster, + image: "mendhak/http-https-echo:33", + port: 8080, + serviceType: "LoadBalancer", ++ replicas: 3, +}); +``` + + + +The Deployment updates in place; repeated curls now return different +hostnames. + +## Deploy again + +Deploy again without editing anything: + + + +## Destroy + + + + + + + + + + + + + + + + + + +## Where next + +- [Part 2: Jobs and CronJobs](/kubernetes/tutorial/part-2) +- [Deployments](/kubernetes/workloads/deployments) +- [Local clusters](/kubernetes/clusters/local) diff --git a/website/src/content/docs/kubernetes/tutorial/part-2.mdx b/website/src/content/docs/kubernetes/tutorial/part-2.mdx new file mode 100644 index 0000000000..1d75cb15d2 --- /dev/null +++ b/website/src/content/docs/kubernetes/tutorial/part-2.mdx @@ -0,0 +1,187 @@ +--- +title: "Part 2: Jobs and CronJobs" +description: Run a container to completion with Kubernetes.Job, then turn it into a CronJob with a schedule. +sidebar: + order: 2 +--- + +import Terminal from "../../../../components/Terminal.astro"; +import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; + +Add a `Kubernetes.Job` to the [Part 1](/kubernetes/tutorial/part-1) +file, then limit retries, clean up, and schedule it. + +## Add a Job + +```diff lang="typescript" +const echo = yield* Kubernetes.Deployment("Echo", { + cluster, + image: "mendhak/http-https-echo:33", + port: 8080, + serviceType: "LoadBalancer", + replicas: 3, +}); + ++const hello = yield* Kubernetes.Job("Hello", { ++ cluster, ++ image: "busybox:1.36", ++ command: ["sh", "-c"], ++ args: ["echo hello from alchemy; date"], ++}); +``` + +A [`Kubernetes.Job`](/kubernetes/workloads/jobs#jobs) runs a container +to completion. It starts as soon as you deploy. + +## Expose the Job name + +```diff lang="typescript" +return { + url: echo.url, + deployment: echo.deploymentName, ++ job: hello.jobName, +}; +``` + +Each change to the Job gets a new name. + +## Deploy and watch it run + + + + + + + + + + + + + + + + + + +```sh +kubectl get jobs +``` + +After a few seconds `COMPLETIONS` reads `1/1`: + +```sh +kubectl logs job/myapp-hello-dev-alex-h6g5f4e3d2c7b2a4-3f9a1c2e +``` + +Deploy doesn't wait for the Job to finish. + +## Change the command and run it again + +```diff lang="typescript" +const hello = yield* Kubernetes.Job("Hello", { + cluster, + image: "busybox:1.36", + command: ["sh", "-c"], +- args: ["echo hello from alchemy; date"], ++ args: ["echo hello again; date"], +}); +``` + + + +A new Job runs under the new name. The old one is +[removed](/kubernetes/guides/how-apply-works#pruning-and-drift). + +## Limit retries with backoffLimit + +```diff lang="typescript" +const hello = yield* Kubernetes.Job("Hello", { + cluster, + image: "busybox:1.36", + command: ["sh", "-c"], + args: ["echo hello again; date"], ++ backoffLimit: 1, +}); +``` + +Retries before the Job is marked failed; the default is 6. + +## Clean up finished Jobs with ttlSecondsAfterFinished + +```diff lang="typescript" +const hello = yield* Kubernetes.Job("Hello", { + cluster, + image: "busybox:1.36", + command: ["sh", "-c"], + args: ["echo hello again; date"], + backoffLimit: 1, ++ ttlSecondsAfterFinished: 60, +}); +``` + +Finished Jobs and their pods are deleted this many seconds after +completion. A later deploy still reports `no changes`. + +## Put it on a schedule + +```diff lang="typescript" +const hello = yield* Kubernetes.Job("Hello", { + cluster, + image: "busybox:1.36", + command: ["sh", "-c"], + args: ["echo hello again; date"], + backoffLimit: 1, + ttlSecondsAfterFinished: 60, ++ schedule: "*/2 * * * *", +}); + +return { + url: echo.url, + deployment: echo.deploymentName, + job: hello.jobName, ++ kind: hello.kind, +}; +``` + + + +Adding a `schedule` turns the Job into a +[`CronJob`](/kubernetes/workloads/jobs#cronjobs). Within two minutes +`kubectl get jobs` shows the first run. + +## Where next + +- [Part 3: Manifests](/kubernetes/tutorial/part-3) +- [Jobs](/kubernetes/workloads/jobs) diff --git a/website/src/content/docs/kubernetes/tutorial/part-3.mdx b/website/src/content/docs/kubernetes/tutorial/part-3.mdx new file mode 100644 index 0000000000..e3e90bd391 --- /dev/null +++ b/website/src/content/docs/kubernetes/tutorial/part-3.mdx @@ -0,0 +1,264 @@ +--- +title: "Part 3: Manifests" +description: Add a Namespace, a ConfigMap, and a Redis StatefulSet with Kubernetes.Manifest and pass their names into your Deployment. +sidebar: + order: 3 +--- + +import Terminal from "../../../../components/Terminal.astro"; +import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; + +Add a Namespace, a ConfigMap, and a Redis StatefulSet with +`Kubernetes.Manifest`, and pass their names into `Echo`. Start from +the [Part 2](/kubernetes/tutorial/part-2) file. + +## Start clean + + + + + + + + + + + + + + + + +Changing a workload's namespace replaces it. + +## Add a Namespace + +```diff lang="typescript" +Effect.gen(function* () { + const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); + ++ const ns = yield* Kubernetes.Manifest("Namespace", { ++ cluster, ++ manifest: { ++ apiVersion: "v1", ++ kind: "Namespace", ++ metadata: { name: "tutorial" }, ++ }, ++ }); +``` + +A [`Kubernetes.Manifest`](/kubernetes/objects/manifests#any-object) +applies any Kubernetes object you write as a literal. + +## Move the workloads into it + +```diff lang="typescript" +const echo = yield* Kubernetes.Deployment("Echo", { + cluster, ++ namespace: ns.name, + image: "mendhak/http-https-echo:33", + port: 8080, + serviceType: "LoadBalancer", + replicas: 3, +}); + +const hello = yield* Kubernetes.Job("Hello", { + cluster, ++ namespace: ns.name, + image: "busybox:1.36", + command: ["sh", "-c"], + args: ["echo hello again; date"], + backoffLimit: 1, + ttlSecondsAfterFinished: 60, + schedule: "*/2 * * * *", +}); +``` + +Pass `ns.name` (an [Output](/infrastructure-as-code/outputs)) rather +than `"tutorial"` so the Namespace is created first. + +## Add a ConfigMap + +```diff lang="typescript" ++const settings = { ++ LOG_LEVEL: "debug", ++ GREETING: "hello from part 3", ++}; + +export default Alchemy.Stack( + // ... + Effect.gen(function* () { + const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); + + const ns = yield* Kubernetes.Manifest("Namespace", { /* ... */ }); + ++ const config = yield* Kubernetes.Manifest("Config", { ++ cluster, ++ manifest: { ++ apiVersion: "v1", ++ kind: "ConfigMap", ++ metadata: { name: "app-config", namespace: ns.name }, ++ data: settings, ++ }, ++ }); +``` + +You can use Outputs anywhere in the manifest. + +## Feed it into the Deployment + +```diff lang="typescript" +const echo = yield* Kubernetes.Deployment("Echo", { + cluster, + namespace: ns.name, + image: "mendhak/http-https-echo:33", + port: 8080, + serviceType: "LoadBalancer", + replicas: 3, ++ env: { ++ ...settings, ++ CONFIG_MAP: config.name, ++ }, +}); +``` + +`config.name` is `"app-config"`. Reuse `settings` for the values; a +Manifest only exposes the object's name. + +## Add Redis as a StatefulSet + +```diff lang="typescript" +const config = yield* Kubernetes.Manifest("Config", { /* ... */ }); + ++const redis = yield* Kubernetes.Manifest("Redis", { ++ cluster, ++ manifest: { ++ apiVersion: "apps/v1", ++ kind: "StatefulSet", ++ metadata: { name: "redis", namespace: ns.name }, ++ spec: { ++ serviceName: "redis", ++ replicas: 1, ++ selector: { matchLabels: { app: "redis" } }, ++ template: { ++ metadata: { labels: { app: "redis" } }, ++ spec: { ++ containers: [ ++ { name: "redis", image: "redis:7", ports: [{ containerPort: 6379 }] }, ++ ], ++ }, ++ }, ++ }, ++ }, ++}); + +const echo = yield* Kubernetes.Deployment("Echo", { /* ... */ }); +``` + +Use a Manifest for any object `Deployment` and `Job` don't cover. + +## Give it a stable address + +```diff lang="typescript" +const redis = yield* Kubernetes.Manifest("Redis", { /* ... */ }); + ++const redisService = yield* Kubernetes.Manifest("RedisService", { ++ cluster, ++ manifest: { ++ apiVersion: "v1", ++ kind: "Service", ++ metadata: { name: "redis", namespace: ns.name }, ++ spec: { ++ type: "ClusterIP", ++ selector: { app: "redis" }, ++ ports: [{ port: 6379, targetPort: 6379 }], ++ }, ++ }, ++}); + +const echo = yield* Kubernetes.Deployment("Echo", { /* ... */ }); +``` + +A [ClusterIP](/kubernetes/guides/exposing-services#service-types) +Service makes Redis reachable inside the cluster as `redis`. + +## Hand the address to the Deployment + +```diff lang="typescript" +import * as Alchemy from "alchemy"; +import * as Kubernetes from "alchemy/Kubernetes"; ++import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; + +// ... + env: { + ...settings, + CONFIG_MAP: config.name, ++ REDIS_URL: Output.interpolate`redis://${redisService.name}:6379`, + }, +``` + +[`Output.interpolate`](/infrastructure-as-code/outputs#interpolate) +builds the URL from the Service name, so the Service is created +before `Echo`. + +## Deploy + + + + + + + + + + + + + + + + + + +Resources are created in the order your references require: +Namespace first, `Echo` last. + +```sh +kubectl get all,configmap -n tutorial +``` + +Check the environment `Echo`'s container received: + +```sh +kubectl get deploy -n tutorial myapp-echo-dev-alex-k3m7p2q6r4s5t2v6 \ + -o jsonpath='{.spec.template.spec.containers[0].env}' +``` + +## Where next + +- [Part 4: Helm Charts](/kubernetes/tutorial/part-4) +- [Manifests](/kubernetes/objects/manifests) +- [Pod template](/kubernetes/workloads/pod-template) diff --git a/website/src/content/docs/kubernetes/tutorial/part-4.mdx b/website/src/content/docs/kubernetes/tutorial/part-4.mdx new file mode 100644 index 0000000000..39f9cd99da --- /dev/null +++ b/website/src/content/docs/kubernetes/tutorial/part-4.mdx @@ -0,0 +1,224 @@ +--- +title: "Part 4: Helm Charts" +description: Install the podinfo Helm chart, pin its version, set values, and see objects removed when the chart stops rendering them. +sidebar: + order: 4 +--- + +import Terminal from "../../../../components/Terminal.astro"; +import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; + +Install a Helm chart, change its values, and remove objects by turning +them off in the chart. Continue from +[Part 3](/kubernetes/tutorial/part-3) with its stack deployed. You need +`helm` on your `PATH` ([Setup](/kubernetes/setup#install)). + +## Install podinfo + +```diff lang="typescript" +const hello = yield* Kubernetes.Job("Hello", { /* ... */ }); + ++const podinfo = yield* Kubernetes.HelmChart("Podinfo", { ++ cluster, ++ chart: "podinfo", ++ repo: "https://stefanprodan.github.io/podinfo", ++ version: "6.14.1", ++ namespace: "podinfo", ++}); + +return { + url: echo.url, + deployment: echo.deploymentName, + job: hello.jobName, + kind: hello.kind, ++ podinfoRelease: podinfo.releaseName, +}; +``` + +Pin `version`, or every deploy picks up the newest release +([all props](/providers/kubernetes/helmchart)). + +## Create the namespace + +```diff lang="typescript" +const podinfo = yield* Kubernetes.HelmChart("Podinfo", { + cluster, + chart: "podinfo", + repo: "https://stefanprodan.github.io/podinfo", + version: "6.14.1", + namespace: "podinfo", ++ createNamespace: true, +}); +``` + +The chart creates the `podinfo` Namespace first and deletes it on +destroy ([lifecycle](/kubernetes/objects/helm#lifecycle)). + +## Deploy + + + + + + + + + + + + + + + + + + +See the three objects: + +```sh +kubectl get all -n podinfo +``` + +Port-forward to the Service (named after the release) and call it: + +```sh +kubectl port-forward -n podinfo svc/myapp-podinfo-dev-alex-q2w3e4r5t6y7u2i3 9898:9898 +curl localhost:9898 +``` + +## Tune it with values + +```diff lang="typescript" +const podinfo = yield* Kubernetes.HelmChart("Podinfo", { + cluster, + chart: "podinfo", + repo: "https://stefanprodan.github.io/podinfo", + version: "6.14.1", + namespace: "podinfo", + createNamespace: true, ++ values: { ++ replicaCount: 2, ++ ui: { message: "hello from alchemy" }, ++ }, +}); +``` + +[`values`](/kubernetes/objects/helm#values) replaces the chart's +`values.yaml`. Deploy again: the plan says `1 to update`, and +`kubectl get deploy -n podinfo` shows `2/2`. + +## Turn the Service off + +Stop the port-forward, then disable the Service: + +```diff lang="typescript" +values: { + replicaCount: 2, + ui: { message: "hello from alchemy" }, ++ service: { enabled: false }, +}, +``` + + + +The chart no longer renders the Service, so it gets +[deleted](/kubernetes/guides/how-apply-works#pruning-and-drift). +`kubectl get svc -n podinfo` is now empty. + +## Put it back + +Remove the `service` line and deploy: + +```diff lang="typescript" +values: { + replicaCount: 2, + ui: { message: "hello from alchemy" }, +- service: { enabled: false }, +}, +``` + + + +The Service is back. + +## No Helm release + +`helm list -n podinfo` shows nothing: there is no Helm release, and +chart hooks don't run +([details](/kubernetes/objects/helm#what-helm-does-that-alchemy-doesnt)). + +## Clean up + + + + + + + + + + + + + + + + + + +The `tutorial` Namespace goes last, after everything inside it. + +## Where next + +- [Part 5: The Same Program on EKS](/kubernetes/tutorial/part-5) — optional +- [Helm charts](/kubernetes/objects/helm) +- [How apply works](/kubernetes/guides/how-apply-works) diff --git a/website/src/content/docs/kubernetes/tutorial/part-5.mdx b/website/src/content/docs/kubernetes/tutorial/part-5.mdx new file mode 100644 index 0000000000..500f4e8df9 --- /dev/null +++ b/website/src/content/docs/kubernetes/tutorial/part-5.mdx @@ -0,0 +1,417 @@ +--- +title: "Part 5: The Same Program on EKS" +description: Deploy the tutorial stack to an EKS cluster, replace the pre-built image with your own Effect server, and bind a DynamoDB table with Pod Identity. +sidebar: + order: 5 +--- + +import Terminal from "../../../../components/Terminal.astro"; +import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; + +Deploy the same stack to EKS, where Alchemy builds your image and +grants AWS permissions to your pods. Start from +[Part 4](/kubernetes/tutorial/part-4) with the stack destroyed. You +need AWS credentials ([AWS Setup](/aws/setup)), Docker, and `helm`. + +:::caution[Costs] +EKS, load balancers, and NAT bill hourly; cluster create and destroy +each take 10–15 minutes. +::: + +## Add the AWS providers + +```diff lang="typescript" +// alchemy.run.ts +import * as Alchemy from "alchemy"; ++import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "MyApp", + { +- providers: Kubernetes.providers(), ++ providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), + state: Alchemy.localState(), + }, +``` + +To deploy to EKS, add `AWS.providers()` next to +`Kubernetes.providers()`. + +## Move shared infrastructure to a module + +Create `src/infra.ts`: + +```typescript +// src/infra.ts +import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; + +export const Network = AWS.EC2.Network("Network", { + cidrBlock: "10.42.0.0/16", + availabilityZones: 2, + nat: "single", +}); + +export const Cluster = Effect.gen(function* () { + const network = yield* Network; + return yield* AWS.EKS.Cluster("Cluster", { + compute: "auto", + resourcesVpcConfig: { + subnetIds: network.privateSubnetIds, + endpointPublicAccess: true, + endpointPrivateAccess: true, + }, + }); +}); + +// Shared with src/api.ts. +export const Namespace = Effect.gen(function* () { + const cluster = yield* Cluster; + return yield* Kubernetes.Manifest("Namespace", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Namespace", + metadata: { name: "tutorial" }, + }, + }); +}); +``` + +Then use it in `alchemy.run.ts`: + +```diff lang="typescript" +// alchemy.run.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; ++import { Namespace } from "./src/infra.ts"; + +// ... + Effect.gen(function* () { + const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); + +- const ns = yield* Kubernetes.Manifest("Namespace", { +- cluster, +- manifest: { +- apiVersion: "v1", +- kind: "Namespace", +- metadata: { name: "tutorial" }, +- }, +- }); ++ const ns = yield* Namespace; +``` + +Both files share one `Namespace`. Cluster props: +[AWS EKS](/aws/compute/eks#create-an-auto-mode-cluster). + +## Swap the cluster + +```diff lang="typescript" +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +-import { Namespace } from "./src/infra.ts"; ++import { Cluster, Namespace } from "./src/infra.ts"; + +// ... + Effect.gen(function* () { +- const cluster = Kubernetes.KubeConfig({ context: "docker-desktop" }); ++ const cluster = yield* Cluster; +``` + +Every resource that takes `cluster` now deploys to EKS. + +## Write your own server + +Create `src/api.ts`: + +```typescript +// src/api.ts +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Cluster, Namespace } from "./infra.ts"; + +export default Kubernetes.Deployment( + "Api", + Effect.gen(function* () { + const cluster = yield* Cluster; + const ns = yield* Namespace; + return { cluster, namespace: ns.name, main: import.meta.url, port: 3000 }; + }), + Effect.gen(function* () { + return { + fetch: Effect.succeed(HttpServerResponse.text("hello from EKS")), + }; + }), +); +``` + +`main: import.meta.url` makes this file the entrypoint. Alchemy +[bundles it](/kubernetes/workloads/image-sources#bundling-and-tree-shaking), +builds an image, and pushes it to +[ECR](/kubernetes/workloads/image-sources#registry-requirement). +[`fetch`](/kubernetes/workloads/deployments#effect-servers) answers +every request on `port`. + +## Add a DynamoDB table + +Append to `src/infra.ts`: + +```diff lang="typescript" +// src/infra.ts +export const Cluster = Effect.gen(function* () { /* ... */ }); + ++export const Entries = AWS.DynamoDB.Table("Entries", { ++ partitionKey: "pk", ++ attributes: { pk: "S" }, ++}); +``` + +## Bind the table + +```diff lang="typescript" +// src/api.ts ++import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +-import { Cluster, Namespace } from "./infra.ts"; ++import { Cluster, Entries, Namespace } from "./infra.ts"; + +export default Kubernetes.Deployment( + "Api", + Effect.gen(function* () { /* ... */ }), + Effect.gen(function* () { ++ const putItem = yield* AWS.DynamoDB.PutItem(Entries); ++ const scan = yield* AWS.DynamoDB.Scan(Entries); + return { + fetch: Effect.succeed(HttpServerResponse.text("hello from EKS")), + }; +- }), ++ }).pipe( ++ Effect.provide( ++ Layer.mergeAll(AWS.DynamoDB.PutItemHttp, AWS.DynamoDB.ScanHttp), ++ ), ++ ), +); +``` + +Each binding sets the table name in +[env and grants IAM permissions](/kubernetes/workloads/bindings#bind-a-resource) +through a [Pod Identity](/kubernetes/workloads/bindings#pod-identity-on-eks) +role. + +## Use it in fetch + +```diff lang="typescript" +// src/api.ts +import * as Layer from "effect/Layer"; ++import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +// ... + const putItem = yield* AWS.DynamoDB.PutItem(Entries); + const scan = yield* AWS.DynamoDB.Scan(Entries); + return { +- fetch: Effect.succeed(HttpServerResponse.text("hello from EKS")), ++ fetch: Effect.gen(function* () { ++ const request = yield* HttpServerRequest; ++ if (request.method === "POST") { ++ const id = yield* Effect.sync(() => crypto.randomUUID()); ++ yield* putItem({ Item: { pk: { S: id } } }); ++ return HttpServerResponse.json({ id }); ++ } ++ const result = yield* scan({}); ++ return HttpServerResponse.json({ count: result.Count ?? 0 }); ++ }).pipe(Effect.orDie), + }; +``` + +`putItem` and `scan` use the pod's AWS credentials. + +## Replace Echo with Api + +In `alchemy.run.ts`: + +```diff lang="typescript" +-import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Cluster, Namespace } from "./src/infra.ts"; ++import Api from "./src/api.ts"; + +// ... +- const echo = yield* Kubernetes.Deployment("Echo", { +- cluster, +- namespace: ns.name, +- image: "mendhak/http-https-echo:33", +- port: 8080, +- serviceType: "LoadBalancer", +- replicas: 3, +- env: { +- ...settings, +- CONFIG_MAP: config.name, +- REDIS_URL: Output.interpolate`redis://${redisService.name}:6379`, +- }, +- }); ++ const api = yield* Api; + + return { +- url: echo.url, +- deployment: echo.deploymentName, ++ url: api.url, ++ identity: api.identity, ++ registry: api.registry, + job: hello.jobName, + kind: hello.kind, + podinfoRelease: podinfo.releaseName, + }; +``` + +`Hello`, the Manifests, and `Podinfo` need no changes. `identity` and +`registry` are only set on EKS. + +## Deploy + + + + + + + + + + + + + + + + + + +`Api` also created an IAM role, a Pod Identity association, and an +ECR repository. They are deleted with the Deployment +([what EKS adds](/kubernetes/clusters/eks#what-eks-adds)). + +## Try it out + +Write two items, then count them: + +```sh +curl -X POST http://k8s-tutorial-myappapi-….elb.us-east-1.amazonaws.com:3000 +curl -X POST http://k8s-tutorial-myappapi-….elb.us-east-1.amazonaws.com:3000 +curl http://k8s-tutorial-myappapi-….elb.us-east-1.amazonaws.com:3000 +# → {"count":2} +``` + +Retry if the first request times out. + +## Ship a change + +Edit the response and deploy again: + +```diff lang="typescript" +// src/api.ts + const result = yield* scan({}); +- return HttpServerResponse.json({ count: result.Count ?? 0 }); ++ return HttpServerResponse.json({ count: result.Count ?? 0, source: "eks" }); +``` + + + +Alchemy rebuilds the image, pushes a new tag, and rolls out the +Deployment. + +## Destroy + + + + + + + + + + + + + + + + + + +Deleting the cluster takes ten minutes or more. + +## Where next + +- [Connecting to clusters](/kubernetes/clusters/connecting) +- [Bindings](/kubernetes/workloads/bindings) +- [EKS clusters](/kubernetes/clusters/eks) diff --git a/website/src/content/docs/kubernetes/workloads/bindings.mdx b/website/src/content/docs/kubernetes/workloads/bindings.mdx new file mode 100644 index 0000000000..eee3a22b4c --- /dev/null +++ b/website/src/content/docs/kubernetes/workloads/bindings.mdx @@ -0,0 +1,209 @@ +--- +title: Bindings +description: Give a Kubernetes Deployment or Job access to other resources with bindings, Pod Identity on EKS, or plain env vars on local clusters. +--- + +Bind a `Kubernetes.Deployment` or `Kubernetes.Job` to other resources. +One +`yield* AWS.DynamoDB.PutItem(table)` in the init Effect sets an env var +on the pod and, on EKS, grants the permission to the pod's IAM role. + +## Bind a resource + +A binding sets env vars on the pod. On +[EKS](/kubernetes/clusters/eks#what-eks-adds) it also grants +IAM permissions, so every AWS binding works unchanged. + +```typescript +// Inside the Stack's Effect.gen body. +// providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()) +const table = yield* AWS.DynamoDB.Table("Entries", { + partitionKey: "pk", + attributes: { pk: "S" }, +}); +const uploads = yield* AWS.S3.Bucket("Uploads", {}); + +const api = yield* Kubernetes.Deployment( + "Api", + { cluster, main: import.meta.url, port: 3000 }, + Effect.gen(function* () { + // Each yield* grants one permission and returns a client to call + // inside fetch. + const putItem = yield* AWS.DynamoDB.PutItem(table); // dynamodb:PutItem on the table + const getObject = yield* AWS.S3.GetObject(uploads); // s3:GetObject on the bucket's objects (+ s3:ListBucket) + + return { + fetch: Effect.gen(function* () { + yield* putItem({ Item: { pk: { S: "hello" } } }); + const object = yield* getObject({ Key: "banner.png" }); + return HttpServerResponse.text( + `banner is ${object.ContentLength} bytes`, + ); + }).pipe(Effect.orDie), + }; + }).pipe( + Effect.provide( + Layer.mergeAll(AWS.DynamoDB.PutItemHttp, AWS.S3.GetObjectHttp), + ), + ), +); +``` + +Reach a bound resource through its client, not by reading its name +from the environment. If your `env` prop sets the same key as a +binding, your value wins. + +## Pod Identity on EKS + +On EKS every workload gets an IAM role, `-pod-role`, bound to its +ServiceAccount, with or without bindings. Binding permissions go in +the role's inline policy, `-pod-policy`. AWS credentials and +`AWS_REGION` are available inside the container; add nothing to +`podTemplate`. Changing +`namespace` replaces the workload. Off EKS, `identity` is `undefined`. + +For an `image:` workload, grant permissions with +`identity.managedPolicyArns`: + +```typescript +// Pre-built image: grant through managed policies. +const worker = yield* Kubernetes.Deployment("Worker", { + cluster, + image: "ghcr.io/acme/worker:1.4", + port: 8080, + serviceType: "ClusterIP", + identity: { + managedPolicyArns: ["arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"], + }, +}); + +worker.identity; // { kind: "aws-pod-identity", roleArn, roleName, associationArn, associationId } +worker.serviceAccountName; // the ServiceAccount the role is bound to +``` + +:::caution[managedPolicyArns is applied on first create only] +Changing the ARNs once the role exists is ignored; bind instead, or replace the workload. +::: + +## Env-only bindings + +On clusters other than EKS, pass values from other resources through +the `env` prop of an `image:` workload. Numbers and objects are +JSON-stringified. + +```typescript +import * as Output from "alchemy/Output"; + +const redis = yield* Kubernetes.Manifest("Redis", { + cluster, + manifest: { + apiVersion: "apps/v1", + kind: "StatefulSet", + metadata: { name: "redis", namespace: "default" }, + spec: { + serviceName: "redis", + selector: { matchLabels: { app: "redis" } }, + template: { + metadata: { labels: { app: "redis" } }, + spec: { containers: [{ name: "redis", image: "redis:7-alpine" }] }, + }, + }, + }, +}); +const redisSvc = yield* Kubernetes.Manifest("RedisService", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Service", + metadata: { name: "redis", namespace: "default" }, + spec: { selector: { app: "redis" }, ports: [{ port: 6379 }] }, + }, +}); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", // pre-built image + port: 8080, + env: { + REDIS_HOST: Output.interpolate`${redisSvc.name}.${redisSvc.namespace}.svc.cluster.local`, + REDIS_PORT: 6379, // lands as the string "6379" + LOG_LEVEL: "info", + }, +}); +``` + +Never put secrets in `env`. Put them in a +[Secret Manifest](/kubernetes/objects/manifests#secrets) and reference +it with `valueFrom.secretKeyRef`. That is a +[container-level field](/kubernetes/workloads/pod-template#container-level-fields), +and [arrays replace](/kubernetes/workloads/pod-template#merge-rules), +so restate the whole container. + +```typescript +import * as Config from "effect/Config"; + +const dbSecret = yield* Kubernetes.Manifest("DbSecret", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Secret", + metadata: { name: "db-credentials", namespace: "default" }, + // Read from the shell that runs `alchemy deploy`; + // never a literal in alchemy.run.ts. + stringData: { password: Config.string("DB_PASSWORD") }, + }, +}); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + env: { DB_HOST: "postgres.default.svc.cluster.local" }, + podTemplate: { + spec: { + // Restate the whole container. + containers: [ + { + name: "api", + image: "ghcr.io/acme/api:v3", + ports: [{ containerPort: 8080 }], + env: [ + { name: "PORT", value: "8080" }, + { name: "DB_HOST", value: "postgres.default.svc.cluster.local" }, + { + name: "DB_PASSWORD", + valueFrom: { + secretKeyRef: { name: dbSecret.name, key: "password" }, + }, + }, + ], + }, + ], + }, + }, +}); +``` + +## Calling a binding + +Call a binding client inside `fetch` or `run`, not in the init Effect; +at init it is a type error. + +```typescript +Effect.gen(function* () { + const putItem = yield* AWS.DynamoDB.PutItem(table); + // yield* putItem({ ... }) ✗ not at init + return { + fetch: Effect.gen(function* () { + yield* putItem({ Item: { pk: { S: "1" } } }); // ✓ + return HttpServerResponse.text("ok"); + }), + }; +}); +``` + +## Where next + +- [Pod template](/kubernetes/workloads/pod-template) +- [Secrets](/kubernetes/objects/manifests#secrets) +- [Bindings](/infrastructure-as-effects/binding) diff --git a/website/src/content/docs/kubernetes/workloads/deployments.mdx b/website/src/content/docs/kubernetes/workloads/deployments.mdx new file mode 100644 index 0000000000..74c07f2fbd --- /dev/null +++ b/website/src/content/docs/kubernetes/workloads/deployments.mdx @@ -0,0 +1,270 @@ +--- +title: Deployments +description: Run a replicated server on any Kubernetes cluster with Kubernetes.Deployment and reach it through its url. +--- + +[`Kubernetes.Deployment`](/providers/kubernetes/deployment) runs a +replicated server. Give it a cluster, an image, and a port. + +```typescript +import * as Kubernetes from "alchemy/Kubernetes"; + +// inside the Stack's Effect.gen body +const local = Kubernetes.KubeConfig({ context: "docker-desktop" }); + +const web = yield* Kubernetes.Deployment("Web", { + cluster: local, + image: "nginx:1.27", // pre-built image; local clusters can't build one + port: 80, + replicas: 2, +}); + +web.url; // LoadBalancer address once assigned (string | undefined) +web.deploymentName; // also serviceName and serviceAccountName +``` + +`image` is +[one of three image sources](/kubernetes/workloads/image-sources#three-sources). + +## What gets created + +This creates a `ServiceAccount`, a `Service`, and a `Deployment`. All +three share one name (`name`, or a generated one) and the +`app.kubernetes.io/name` label plus your `labels`. Your `env` wins over +anything set for you (`ALCHEMY_*`, `PORT`, binding env). + +```yaml +# What `Kubernetes.Deployment("Web", { image: "nginx:1.27", port: 80, replicas: 2 })` applies +apiVersion: v1 +kind: ServiceAccount +metadata: { name: myapp-web-dev-alex-a1b2c3d4e5f6g7h8, namespace: default, labels: { app.kubernetes.io/name: myapp-web-dev-alex-a1b2c3d4e5f6g7h8 } } +--- +apiVersion: v1 +kind: Service +metadata: { name: myapp-web-dev-alex-…, namespace: default, labels: { app.kubernetes.io/name: myapp-web-dev-alex-… } } +spec: + type: LoadBalancer + selector: { app.kubernetes.io/name: myapp-web-dev-alex-… } + ports: [{ port: 80, targetPort: 80, protocol: TCP }] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: { name: myapp-web-dev-alex-…, namespace: default, labels: { app.kubernetes.io/name: myapp-web-dev-alex-… } } +spec: + replicas: 2 + selector: { matchLabels: { app.kubernetes.io/name: myapp-web-dev-alex-… } } + template: + metadata: { labels: { app.kubernetes.io/name: myapp-web-dev-alex-… } } + spec: + serviceAccountName: myapp-web-dev-alex-… + containers: + - name: myapp-web-dev-alex-… + image: nginx:1.27 + ports: [{ containerPort: 80 }] + env: + - { name: ALCHEMY_STACK_NAME, value: MyApp } + - { name: ALCHEMY_STAGE, value: dev_alex } + - { name: ALCHEMY_PHASE, value: runtime } + - { name: PORT, value: "80" } +``` + +On EKS you also get an IAM role and an ECR repository, shown in +`identity` and `registry` ([What EKS adds](/kubernetes/clusters/eks#what-eks-adds)). + +## Props by group + +Every prop is in the [reference](/providers/kubernetes/deployment). +Sizing: `replicas`, `resources`. Container: `command`, `args`, `env`, +`port` (default `3000`). Placement: `namespace` (create it first with +a [Namespace manifest](/kubernetes/objects/manifests#namespaces) and +pass its `name`), `labels`, +[`podTemplate`](/kubernetes/workloads/pod-template#merge-rules). +Cloud: `architecture`, `identity`, `tags`, `name`. + +```typescript +const ns = yield* Kubernetes.Manifest("AppNamespace", { + cluster, + manifest: { apiVersion: "v1", kind: "Namespace", metadata: { name: "app" } }, +}); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + // sizing + replicas: 3, + resources: { + requests: { cpu: "100m", memory: "128Mi" }, + limits: { cpu: "500m", memory: "256Mi" }, + }, + // the container + port: 8080, + args: ["--log-level", "info"], + env: { LOG_FORMAT: "json" }, + // placement — pass `ns.name`, not "app", so the namespace is created first + namespace: ns.name, + labels: { "app.kubernetes.io/part-of": "acme" }, +}); +``` + +## Exposing it: serviceType and url + +`serviceType` defaults to `"LoadBalancer"`, which gives you `url`. +With `"ClusterIP"` or `"NodePort"` it is `undefined`. If no address +arrives within about 3 minutes, `url` stays `undefined` until your +next deploy. Your `serviceAnnotations` win over the cluster's defaults. + +```typescript +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + // default — provisions the cluster's cloud load balancer + serviceType: "LoadBalancer", + // yours win over the cluster's defaults (EKS: internet-facing NLB) + serviceAnnotations: { + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", + }, +}); + +// http://:8080, or undefined until the address arrives +return { url: api.url }; + +// In-cluster only: no load balancer, `url` is always undefined. +const worker = yield* Kubernetes.Deployment("Internal", { + cluster, + image: "ghcr.io/acme/worker:v3", + serviceType: "ClusterIP", +}); +// Reach it from other pods as http://..svc: +``` + +For Ingress and per-cloud annotations, see +[Service types](/kubernetes/guides/exposing-services#service-types). + +## Effect servers + +To run your own code, pass `main: import.meta.url` and an Effect that +returns `{ fetch }`. The outer Effect runs once per pod; `fetch` serves +every request on `PORT` and must never fail, so end it with +`Effect.orDie`. + +```typescript +import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +// inside the Stack body (EKS only) +const api = yield* Kubernetes.Deployment( + "Api", + { cluster, main: import.meta.url, port: 3000, replicas: 2 }, + // runs once per pod at startup — resolve bindings, build clients + Effect.gen(function* () { + const getItem = yield* AWS.DynamoDB.GetItem(table); + return { + // fetch: runs per request on PORT + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://api"); + if (url.pathname === "/health") { + return HttpServerResponse.text("ok"); + } + const item = yield* getItem({ Key: { pk: { S: url.pathname } } }); + return yield* HttpServerResponse.json(item.Item ?? null); + }).pipe(Effect.orDie), // fetch must never fail + }; + }).pipe(Effect.provide(AWS.DynamoDB.GetItemHttp)), +); +``` + +You can also put the Deployment in its own module and `export default` +it, with `props` as an Effect. + +```typescript +// src/api.ts — the same call as a module default export. +// `props` is an Effect so it can reference resources declared in other modules. +export default Kubernetes.Deployment( + "Api", + Effect.gen(function* () { + const cluster = yield* Cluster; // from ./infra.ts + const ns = yield* AppNamespace; + return { cluster, main: import.meta.url, namespace: ns.name, port: 3000 }; + }), + Effect.gen(function* () { + return { fetch: Effect.succeed(HttpServerResponse.text("ok")) }; + }), +); + +// alchemy.run.ts — `import Api from "./src/api.ts"` then, in the Stack body: +const api = yield* Api; +``` + +To bind AWS resources, see [Bindings](/kubernetes/workloads/bindings). + +:::caution[`main` works on EKS only] +On other clusters use `image:` ([Registry requirement](/kubernetes/workloads/image-sources#registry-requirement)). +::: + +## The tagged form + +Declare a class with `Kubernetes.Deployment()("Api")`, +build its Layer with `Api.make(props, impl)`, then provide the Layer in +the Stack and `yield*` the class. + +```typescript +// src/api.ts +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { Cluster, AppNamespace } from "./infra.ts"; + +export class Api extends Kubernetes.Deployment Effect.Effect; +}>()("Api") {} + +export default Api.make( + // props can be an Effect so they can reference resources from other modules + Effect.gen(function* () { + const cluster = yield* Cluster; + const ns = yield* AppNamespace; + return { cluster, main: import.meta.url, namespace: ns.name, port: 3000 }; + }), + Effect.gen(function* () { + return { + fetch: Effect.succeed(HttpServerResponse.text("ok")), + health: () => Effect.succeed("ok"), + }; + }), +); +``` + +```typescript +// alchemy.run.ts — provide the Layer, yield the class +import ApiLive, { Api } from "./src/api.ts"; + +export default Alchemy.Stack( + "MyApp", + { providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), state: Alchemy.localState() }, + Effect.gen(function* () { + const api = yield* Api; + return { url: api.url }; + }).pipe(Effect.provide(ApiLive)), +); +``` + +The same form works for a +[`Kubernetes.Job`](/kubernetes/workloads/jobs#effect-jobs). + +## Updates and replacement + +Changing `cluster` or `namespace` replaces the Deployment. Anything +else updates in place, rolling pods when the pod template changes. +`labels` can't change after create; put labels you'll edit later in +`podTemplate.metadata.labels`. + +## Where next + +- [Jobs](/kubernetes/workloads/jobs) +- [Image sources](/kubernetes/workloads/image-sources) +- [`Deployment` reference](/providers/kubernetes/deployment) diff --git a/website/src/content/docs/kubernetes/workloads/image-sources.mdx b/website/src/content/docs/kubernetes/workloads/image-sources.mdx new file mode 100644 index 0000000000..3c76dce3dc --- /dev/null +++ b/website/src/content/docs/kubernetes/workloads/image-sources.mdx @@ -0,0 +1,196 @@ +--- +title: Image sources +description: Choose where a Deployment or Job gets its container image, from a pre-built image, your own Dockerfile, or a bundled Effect program. +--- + +Every workload needs a container image. Set exactly one of `image` +(pre-built), `context` (your Dockerfile), or `main` (an Effect program +Alchemy bundles). + +```typescript +// 1. a pre-built image, run as-is +yield* Kubernetes.Deployment("Web", { cluster, image: "nginx:1.27", port: 80 }); + +// 2. your own Dockerfile — built from ./legacy, runs its CMD/ENTRYPOINT +yield* Kubernetes.Deployment("Legacy", { cluster, context: "./legacy", port: 8080 }); + +// 3. an Effect program, bundled into an image and served on PORT +yield* Kubernetes.Deployment( + "Api", + { cluster, main: import.meta.url, port: 3000 }, + Effect.gen(function* () { + return { fetch: Effect.succeed(HttpServerResponse.text("ok")) }; + }), +); +``` + +`main` returns `{ fetch }` for a +[Deployment](/kubernetes/workloads/deployments#effect-servers) and +`{ run }` for a [Job](/kubernetes/workloads/jobs#effect-jobs). + +## Three sources + +If you set `main`, the other keys describe its base image. + +**`image`** is a registry reference. On EKS it is copied into ECR; +elsewhere the nodes pull it as written, so a private registry needs an +`imagePullSecrets` [recipe](/kubernetes/workloads/pod-template#recipes). + +**`context` + `dockerfile`** runs `docker build` in your context. +`dockerfile` is a path relative to the cwd (default +`${context}/Dockerfile`) or `Docker.Dockerfile.inline` content, which +has no context and can't `COPY`. Your Dockerfile supplies `CMD` / +`ENTRYPOINT`. Never interpolate secrets into it. + +**`main`** is bundled into a generated image. `image` is the `FROM` +base (default `oven/bun:1`; must run bun). `dockerfile` + `context` +builds your Dockerfile first and uses it as the base. An inline +`dockerfile` replaces the opening lines. + +```dockerfile +# base: `FROM ` (default oven/bun:1), your inline +# `dockerfile`, or `FROM` your built Dockerfile +FROM oven/bun:1 +WORKDIR /app +COPY index.mjs /app/index.mjs +# extra bundle chunks +COPY *.js /app/ +# Deployments only — Jobs get neither line +ENV PORT=3000 +EXPOSE 3000 +ENTRYPOINT ["bun", "/app/index.mjs"] +``` + +```typescript +import * as Docker from "alchemy/Docker"; // Docker.Dockerfile.inline (used below) + +// `main` on a different base image: Debian-based bun +const api = yield* Kubernetes.Deployment( + "Api", + { cluster, main: import.meta.url, image: "oven/bun:1-debian", port: 3000 }, + Effect.gen(function* () { /* ... */ }), +); + +// `main` on your own Dockerfile: ./env/Dockerfile is built first, +// then the bundle is added on top +const worker = yield* Kubernetes.Job( + "Render", + { cluster, main: import.meta.url, context: "./env", dockerfile: "./env/Dockerfile" }, + Effect.gen(function* () { /* ... */ }), +); + +// inline base image (no local files, so no COPY) +const tools = yield* Kubernetes.Job( + "Tools", + { + cluster, + main: import.meta.url, + dockerfile: Docker.Dockerfile.inline` + FROM oven/bun:1 + RUN apt-get update && apt-get install -y ffmpeg + `, + }, + Effect.gen(function* () { /* ... */ }), +); +``` + +Full prop list: [Deployment](/providers/kubernetes/deployment), +[Job](/providers/kubernetes/job). + +:::caution[Mutable tags] +`acme/api:latest` is copied into ECR once; pin a version tag or digest. +::: + +## Registry requirement + +`main` and `context` build an image and push it to a registry, so they +need a cluster with one (EKS). On EKS every source, `image` included, +is pushed to ECR. On other clusters only `image` works, as written; +`main` or `context` fail the deploy: + +> `'': this cluster has no managed image registry, so 'main' and 'context' image sources cannot be built and pushed. …` + +```typescript +// Local cluster: only `image`, used as written. +const local = Kubernetes.KubeConfig({ context: "docker-desktop" }); +const web = yield* Kubernetes.Deployment("Web", { + cluster: local, + image: "ghcr.io/acme/web:1.4.2", // appears in the pod spec exactly like this + port: 8080, +}); +web.imageUri; // "ghcr.io/acme/web:1.4.2" +web.registry; // undefined + +// EKS: the same image is copied into ECR. +const eks = yield* AWS.EKS.Cluster("Cluster", { compute: "auto", /* ... */ }); +const web2 = yield* Kubernetes.Deployment("Web", { + cluster: eks, + image: "ghcr.io/acme/web:1.4.2", + port: 8080, +}); +web2.imageUri; // the ECR copy, not the ghcr ref +web2.registry; // { kind: "aws-ecr", repositoryName, repositoryUri } +``` + +On EKS every source, `image` included, needs Docker running where you +deploy. On other clusters `image:` needs no Docker. + +## Architecture + +Set `architecture: "amd64" | "arm64"` (default `"amd64"`) to match +your nodes. A mismatch fails at pod start with `exec format error`. + +```typescript +const api = yield* Kubernetes.Deployment( + "Api", + { + cluster, + main: import.meta.url, + architecture: "arm64", // build + push a linux/arm64 image + podTemplate: { spec: { nodeSelector: { "kubernetes.io/arch": "arm64" } } }, + }, + Effect.gen(function* () { /* ... */ }), +); +``` + +On other clusters, nodes pull `image` for their own architecture. + +## Bundling and tree-shaking + +`main` is bundled with rolldown; `build.input` and `build.output` +pass through to it. Minify and sourcemaps are off by default. + +Unused code from `effect`, `@effect/*`, `alchemy`, `@alchemy.run/*`, +and `@distilled.cloud/*` is dropped. Add your own packages with +`build.pure.packages` (`replaceDefaults: true` drops the defaults); +`build: { pure: false }` turns it off. `build.bundleAnalyzer: true` +writes a report. + +```typescript +const api = yield* Kubernetes.Deployment( + "Api", + { + cluster, + main: import.meta.url, + build: { + // also drop unused code from your own packages + pure: { packages: ["@acme/*", "my-router"] }, + // rolldown passthrough + output: { minify: true }, + input: { external: ["sharp"] }, + }, + }, + Effect.gen(function* () { /* ... */ }), +); + +// keep all code (when a side effect goes missing) +{ main: import.meta.url, build: { pure: false } } +``` + +Only add a package whose top-level calls really are side-effect free. + +## Where next + +- [Bindings](/kubernetes/workloads/bindings) +- [Using your own images](/kubernetes/clusters/local#using-your-own-images) +- [What EKS adds](/kubernetes/clusters/eks#what-eks-adds) diff --git a/website/src/content/docs/kubernetes/workloads/jobs.mdx b/website/src/content/docs/kubernetes/workloads/jobs.mdx new file mode 100644 index 0000000000..226a3d7f0f --- /dev/null +++ b/website/src/content/docs/kubernetes/workloads/jobs.mdx @@ -0,0 +1,139 @@ +--- +title: Jobs +description: Run a container to completion or on a cron schedule on any Kubernetes cluster with Kubernetes.Job. +--- + +[`Kubernetes.Job`](/providers/kubernetes/job) runs a container to +completion. It takes the same `cluster`, +[image sources](/kubernetes/workloads/image-sources#three-sources), +and bindings as a Deployment, but has no Service and no `url`. + +```typescript +// inside the Stack body +const migrate = yield* Kubernetes.Job("DbMigrate", { + cluster, + image: "ghcr.io/acme/migrator:v3", + args: ["migrate", "--to", "latest"], + backoffLimit: 2, +}); + +migrate.jobName; // generated; changes with every edit to the Job +migrate.kind; // "Job" +``` + +## Jobs + +A Job creates a `ServiceAccount` and a `batch/v1 Job`, named +[like a Deployment](/kubernetes/workloads/deployments#what-gets-created) +with the same [props](/kubernetes/workloads/deployments#props-by-group). +The Job starts on deploy. Deploy doesn't wait for it to finish. + +Redeploying with nothing changed does nothing. Any change deletes the +old Job and runs a new one (bump an env var to force a re-run). Set +`ttlSecondsAfterFinished` on anything you deploy repeatedly. + +```typescript +const backfill = yield* Kubernetes.Job("Backfill", { + cluster, + image: "ghcr.io/acme/tools:v3", + command: ["node", "backfill.js"], + args: ["--since", "2026-01-01"], + // each failed attempt is a fresh pod; the failed pod stays for `kubectl logs` + restartPolicy: "Never", + // give up after 3 retries (Kubernetes default is 6) + backoffLimit: 3, + // clean up the finished Job (and its pods) an hour after it ends + ttlSecondsAfterFinished: 3600, + resources: { requests: { cpu: "250m", memory: "512Mi" } }, +}); +``` + +:::caution +A failed Job does not fail the deploy; see +[Debugging](/kubernetes/guides/how-apply-works#debugging). +::: + +## CronJobs + +Set `schedule` to a 5-field cron expression to get a `batch/v1 CronJob` +instead. It updates in place and never runs on deploy. For fields +without props (`concurrencyPolicy`, `timeZone`) write a +[`Kubernetes.Manifest`](/kubernetes/objects/manifests#any-object). + +```typescript +const nightly = yield* Kubernetes.Job("NightlyReport", { + cluster, + image: "ghcr.io/acme/tools:v3", + args: ["report", "--yesterday"], + // 5-field cron → a CronJob; runs at 03:00 cluster time + schedule: "0 3 * * *", + // these apply to each run's Job + backoffLimit: 1, + ttlSecondsAfterFinished: 86400, +}); + +nightly.kind; // "CronJob" +nightly.schedule; // "0 3 * * *" +``` + +## Effect jobs + +To run your own code, pass `main: import.meta.url` and an Effect that +returns `{ run }`. When `run` finishes the pod exits 0; a failure exits +1 and counts against `backoffLimit`. Bindings work as in +[Effect servers](/kubernetes/workloads/deployments#effect-servers). + +```typescript +import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Effect from "effect/Effect"; + +// inside the Stack body (EKS only) +const seed = yield* Kubernetes.Job( + "SeedData", + { cluster, main: import.meta.url, backoffLimit: 2, ttlSecondsAfterFinished: 600 }, + Effect.gen(function* () { + // runs once at startup: bindings and clients + const putItem = yield* AWS.DynamoDB.PutItem(table); + return { + // run: executes to completion, then the pod exits 0 + run: Effect.gen(function* () { + yield* putItem({ Item: { pk: { S: "seed#1" }, message: { S: "hello" } } }); + yield* Effect.log("seeded"); + }).pipe(Effect.orDie), // a failure exits 1 → counts against backoffLimit + }; + }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)), +); +``` + +The [tagged form](/kubernetes/workloads/deployments#the-tagged-form) +works too, and `schedule` works with `main`. + +```typescript +// src/backfill.ts — the tagged form (same shape as a tagged Deployment) +export class Backfill extends Kubernetes.Job Effect.Effect; +}>()("Backfill") {} + +export default Backfill.make( + Effect.gen(function* () { + const cluster = yield* Cluster; + return { cluster, main: import.meta.url, schedule: "0 3 * * *" }; // an Effect CronJob + }), + Effect.gen(function* () { + return { + run: Effect.log("backfilling…"), + progress: () => Effect.succeed(0), + }; + }), +); +``` + +`main` works on [EKS only](/kubernetes/workloads/image-sources#registry-requirement). +On other clusters use `image:` with `command` / `args`. + +## Where next + +- [Image sources](/kubernetes/workloads/image-sources) +- [Bindings](/kubernetes/workloads/bindings) +- [`Job` reference](/providers/kubernetes/job) diff --git a/website/src/content/docs/kubernetes/workloads/pod-template.mdx b/website/src/content/docs/kubernetes/workloads/pod-template.mdx new file mode 100644 index 0000000000..8f6e6f2568 --- /dev/null +++ b/website/src/content/docs/kubernetes/workloads/pod-template.mdx @@ -0,0 +1,308 @@ +--- +title: Pod template +description: Customize the pod spec of a Kubernetes Deployment or Job with podTemplate, including merge rules, container overrides, and recipes for common settings. +--- + +`podTemplate` is a partial `PodTemplateSpec` merged onto the default +one for a Deployment or Job. Objects merge, arrays and primitives +replace. + +## The default pod template + +`command`, `args`, `resources`, `env`, and `port` already set the +container, so use the prop when one exists. + +```typescript +// The pod template your `podTemplate` is merged onto. +{ + metadata: { labels: { "app.kubernetes.io/name": , ...labels } }, + spec: { + serviceAccountName: , // Job also: restartPolicy: "Never" | "OnFailure" + containers: [ + { + name: , + image: , // ECR URI on EKS; your `image` elsewhere + command, args, // from props + ports: [{ containerPort: port }], // Deployment only + env: [ /* ALCHEMY_*, PORT, your env */ ], + resources, // from props + }, + ], + }, +} +``` + +The default has no `volumes`, `initContainers`, `tolerations`, +`affinity`, `securityContext`, or `imagePullSecrets`. + +## Merge rules + +Objects merge recursively; arrays and primitives replace. `containers` +is the one array the default already fills. Don't set +`spec.serviceAccountName`: on EKS it breaks +[Pod Identity](/kubernetes/workloads/bindings#pod-identity-on-eks). + +```typescript +// ❌ Adding a readiness probe like this replaces `containers` with this +// ONE object: no name, no image, no env. The deploy fails because a +// container needs `name` and `image`. +podTemplate: { + spec: { + containers: [ + { readinessProbe: { httpGet: { path: "/health", port: 3000 } } }, + ], + }, +}, +``` + +```typescript +// What reaches the cluster for that override: +spec: { + serviceAccountName: "api-…", + containers: [{ readinessProbe: { httpGet: { path: "/health", port: 3000 } } }], +} +``` + +Changing `podTemplate` updates the workload +[in place](/kubernetes/guides/how-apply-works#server-side-apply). + +## Container-level fields + +Anything on the container that is not a prop (probes, `volumeMounts`, +`envFrom`, `valueFrom`, `imagePullPolicy`, container `securityContext`) +needs the full container override. Works with `image:` only. + +```typescript +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + podTemplate: { + spec: { + containers: [ + { + name: "api", + image: "ghcr.io/acme/api:v3", // same string as `image` + ports: [{ containerPort: 8080 }], + env: [{ name: "PORT", value: "8080" }], // restate anything you need + readinessProbe: { httpGet: { path: "/health", port: 8080 }, periodSeconds: 5 }, + livenessProbe: { httpGet: { path: "/health", port: 8080 }, initialDelaySeconds: 10 }, + }, + ], + }, + }, +}); +``` + +A `main:` Effect server gets no probe, so answer `/` quickly. + +:::tip +If the override grows beyond a few lines, or you need container-level fields on `main:`, write a [`Kubernetes.Manifest`](/kubernetes/objects/manifests#any-object) instead. +::: + +## Recipes + +### Tolerations and nodeSelector + +Match `kubernetes.io/arch` to the +[`architecture`](/kubernetes/workloads/image-sources#architecture) prop. + +```typescript +podTemplate: { + spec: { + tolerations: [{ key: "nvidia.com/gpu", operator: "Exists", effect: "NoSchedule" }], + nodeSelector: { "kubernetes.io/arch": "arm64", pool: "gpu" }, + }, +}, +``` + +### Affinity and spread + +`podAntiAffinity` needs a literal label, so add one with `labels`. + +```typescript +labels: { app: "api" }, +podTemplate: { + spec: { + affinity: { + podAntiAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [{ + weight: 100, + podAffinityTerm: { + labelSelector: { matchLabels: { app: "api" } }, + topologyKey: "kubernetes.io/hostname", + }, + }], + }, + }, + }, +}, +``` + +### Pod securityContext + +Container-level `securityContext` (`capabilities`, +`readOnlyRootFilesystem`) needs the +[container override](#container-level-fields). + +```typescript +podTemplate: { + spec: { + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + fsGroup: 1000, + seccompProfile: { type: "RuntimeDefault" }, + }, + }, +}, +``` + +### imagePullSecrets + +Pull a private `image:` on a cluster without a registry (EKS doesn't +need this). Create the Secret as a +[Manifest](/kubernetes/objects/manifests#secrets). + +```typescript +import * as Config from "effect/Config"; + +const pull = yield* Kubernetes.Manifest("GhcrPull", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Secret", + metadata: { name: "ghcr-pull", namespace: "default" }, + type: "kubernetes.io/dockerconfigjson", + // The contents of ~/.docker/config.json for the registry, read from the env. + stringData: { ".dockerconfigjson": Config.string("GHCR_DOCKER_CONFIG_JSON") }, + }, +}); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/private-api:v3", + port: 8080, + podTemplate: { spec: { imagePullSecrets: [{ name: pull.name }] } }, +}); +``` + +### Volumes: emptyDir and a PVC from a Manifest + +`spec.volumes` merges in; `volumeMounts` needs the container override. +The PVC is a [Manifest](/kubernetes/objects/manifests#any-object). + +```typescript +const data = yield* Kubernetes.Manifest("ApiData", { + cluster, + manifest: { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { name: "api-data", namespace: "default" }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "10Gi" } } }, + }, +}); + +const api = yield* Kubernetes.Deployment("Api", { + cluster, + image: "ghcr.io/acme/api:v3", + port: 8080, + podTemplate: { + spec: { + volumes: [ + { name: "scratch", emptyDir: {} }, + { name: "data", persistentVolumeClaim: { claimName: data.name } }, + ], + containers: [{ // full override: mounts are container-level + name: "api", + image: "ghcr.io/acme/api:v3", + ports: [{ containerPort: 8080 }], + volumeMounts: [ + { name: "scratch", mountPath: "/tmp/scratch" }, + { name: "data", mountPath: "/var/lib/api" }, + ], + }], + }, + }, +}); +``` + +### Sidecar container + +A native sidecar (Kubernetes 1.29+) leaves the main container +untouched, so it works with `main:` and `context:` too. + +```typescript +podTemplate: { + spec: { + initContainers: [{ + name: "log-shipper", + image: "fluent/fluent-bit:3.1", + restartPolicy: "Always", // native sidecar: starts before the app, stops after it + volumeMounts: [{ name: "scratch", mountPath: "/var/log/app" }], + }], + volumes: [{ name: "scratch", emptyDir: {} }], + }, +}, +``` + +### imagePullPolicy + +For a local cluster re-tagging the same `image:` string +([Using your own images](/kubernetes/clusters/local#using-your-own-images)). + +```typescript +podTemplate: { + spec: { + containers: [{ + name: "api", + image: "my-api:dev", // same string as `image` + imagePullPolicy: "IfNotPresent", // pull only if the node doesn't have it + ports: [{ containerPort: 8080 }], + env: [{ name: "PORT", value: "8080" }], + }], + }, +}, +``` + +### envFrom a ConfigMap or Secret + +Every key of a ConfigMap or +[Secret Manifest](/kubernetes/objects/manifests#secrets) becomes an +env var. + +```typescript +podTemplate: { + spec: { + containers: [{ + name: "api", + image: "ghcr.io/acme/api:v3", + ports: [{ containerPort: 8080 }], + env: [{ name: "PORT", value: "8080" }], + envFrom: [ + { configMapRef: { name: config.name } }, + { secretRef: { name: dbSecret.name } }, + ], + }], + }, +}, +``` + +### Annotations and grace period + +Both merge in. + +```typescript +podTemplate: { + metadata: { + annotations: { "prometheus.io/scrape": "true", "prometheus.io/port": "3000" }, + }, + spec: { terminationGracePeriodSeconds: 30 }, +}, +``` + +## Where next + +- [Manifests](/kubernetes/objects/manifests) +- [How apply works](/kubernetes/guides/how-apply-works#server-side-apply) +- [`Deployment` reference](/providers/kubernetes/deployment) diff --git a/website/src/docs-tabs.ts b/website/src/docs-tabs.ts index 3ce627de12..26af239004 100644 --- a/website/src/docs-tabs.ts +++ b/website/src/docs-tabs.ts @@ -54,6 +54,12 @@ export const DOCS_TABS: DocsTab[] = [ prefixes: ["/fly", "/providers/fly"], slot: "primary", }, + { + label: "Kubernetes", + href: "/kubernetes", + prefixes: ["/kubernetes", "/providers/kubernetes"], + slot: "primary", + }, { label: "PlanetScale", href: "/planetscale",