@dmgnr/kuber 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +439 -91
  2. package/dist/index.js +191 -194
  3. package/package.json +4 -3
  4. package/types.d.ts +0 -7
package/README.md CHANGED
@@ -1,45 +1,280 @@
1
1
  # kuber
2
2
 
3
- `kuber` is Astral's internal Docker Compose to Kubernetes translation layer.
4
-
5
- It reads a local Compose file, renders Kubernetes resources, applies them to the cluster, and can build images remotely from your working tree without requiring Docker on your machine.
6
-
7
- ## What It Does
8
-
9
- - Reads `compose.yml` or `docker-compose.yml`
10
- - Converts supported Compose services into Kubernetes resources
11
- - Applies those resources into a namespace named after the current directory
12
- - Turns `env_file` entries into Kubernetes Secrets and mounts them through `envFrom`
13
- - Translates file mounts into ConfigMaps and directory/volume mounts into PVC-backed volumes
14
- - Builds images on the remote builder by syncing:
15
- - committed git state
16
- - tracked local diffs
17
- - untracked files
18
- - ignored `.env*` files
19
- - Supports host-based `ports` syntax that renders Kubernetes `Ingress` rules
20
- - Supports managed Postgres claims through special pseudo-volumes such as `postgresql:app`
21
- - Supports managed S3 buckets and credentials through pseudo-volumes such as `s3:app`
3
+ `kuber` is a Docker Compose to Kubernetes translation layer backed by a
4
+ self-hosted management service (`kuber-server`). It reads a local Compose file,
5
+ renders Kubernetes resources, and applies them to the cluster through an
6
+ authenticated v2 API. Image builds happen inside the cluster with rootless
7
+ BuildKit, so the workstation needs neither Docker nor `kubectl`.
22
8
 
23
- ## Environment Assumptions
9
+ ## Architecture
10
+
11
+ ```
12
+ workstation (kuber CLI) ──HTTPS──▶ kuber-server (in-cluster pod)
13
+ │
14
+ ├─ CAS (content-addressed blobs on RWX PVC)
15
+ ├─ BuildKit Jobs (rootless) ──▶ registry
16
+ └─ Kubernetes API (RBAC-scoped)
17
+ ```
24
18
 
25
- This tool is built for Astral's cluster and workstation setup. It is not intended to work unchanged outside that environment.
19
+ The CLI authenticates to the server with a Bearer token acquired by `kuber
20
+ login`. It snapshots the repository into a content-addressed workspace, uploads
21
+ only the blobs the server is missing, and submits a build request. The server
22
+ materializes that workspace onto a shared RWX PVC and runs a rootless BuildKit
23
+ Job that builds and pushes the image, then the CLI pins the resulting immutable
24
+ registry digest into the rendered Deployment.
26
25
 
27
- Expected local setup:
26
+ The server also owns reconciliation: it plans, applies, and prunes resources in
27
+ a per-project namespace, reconciles managed Postgres and S3 claims, rolls
28
+ deployments back, streams logs, and exposes interactive `exec` sessions over a
29
+ WebSocket.
28
30
 
29
- - Tailscale access to the remote builder and Kubernetes network
30
- - a working `~/.kube/config`
31
- - `ssh` available locally
31
+ ## Environment Assumptions
32
+
33
+ kuber targets a specific self-hosted cluster and workstation setup. It is not
34
+ intended to run unchanged against an arbitrary Kubernetes environment. The
35
+ expected setup:
36
+
37
+ - an account on the `kuber-server` management API
38
+ - a working ClusterRole/Role (see [Server Deployment](#server-deployment))
39
+ - a registry the in-cluster BuildKit can push to
32
40
 
33
41
  Not required locally:
34
42
 
35
43
  - Docker
36
44
  - `kubectl`
37
45
 
38
- Docker is not required because builds happen on the remote builder after `kuber` syncs your repo state there. `kubectl` is not required because cluster access is handled through the bundled Kubernetes client.
46
+ ## API Origin
47
+
48
+ The v2 management API has one hard-coded origin:
49
+
50
+ ```text
51
+ https://kuber.astrxl.dev/api/v2
52
+ ```
53
+
54
+ ## Authentication
55
+
56
+ ```bash
57
+ kuber login dmgnr
58
+ kuber login dmgnr --persist
59
+ kuber whoami
60
+ kuber logout
61
+ ```
62
+
63
+ The default login is stored with mode `0600` under
64
+ `$XDG_RUNTIME_DIR/kuber/session.json` and disappears with the user runtime
65
+ directory. `--persist` instead uses `$XDG_CONFIG_HOME/kuber/session.json`, or
66
+ `~/.config/kuber/session.json` when `XDG_CONFIG_HOME` is unset. Password input
67
+ is never echoed. Runtime sessions last 24 hours and persistent sessions last 30
68
+ days; `logout` revokes the server-side session. `readSession` falls back to the
69
+ persistent file when no runtime session exists.
70
+
71
+ The default login is scoped to the current user and the authenticated identity
72
+ is available to every v2 command. `login`, `logout`, and `whoami` are the only
73
+ commands that run without a loaded project configuration.
74
+
75
+ ### Roles and Authorization
76
+
77
+ The server grants capabilities through three roles:
78
+
79
+ - `viewer` — read-only cluster access (`kubernetes:read`)
80
+ - `operator` — `viewer` plus `kubernetes:write` and `kubernetes:exec`
81
+ - `admin` — all capabilities, including user administration
82
+ (`users:read`, `users:write`, `sessions:revoke`)
83
+
84
+ Administer users with the `users` command tree:
85
+
86
+ ```bash
87
+ kuber users ls
88
+ kuber users add dmgnr --roles admin
89
+ kuber users update dmgnr --roles operator
90
+ kuber users update dmgnr --password
91
+ kuber users disable dmgnr
92
+ kuber users enable dmgnr
93
+ kuber users delete dmgnr
94
+ kuber users revoke dmgnr
95
+ ```
96
+
97
+ `users add`, `update --password`, and `delete` prompt for password / written
98
+ confirmation on an interactive terminal. Passwords are hashed with Argon2id on
99
+ the server and never stored in plaintext. Updating a user's roles or password
100
+ revokes all of that user's active sessions.
101
+
102
+ Inspect server-side operations and the audit trail:
103
+
104
+ ```bash
105
+ kuber operations ls
106
+ kuber operations get <operation-id>
107
+ kuber audit ls
108
+ ```
109
+
110
+ ## Server Deployment
111
+
112
+ The repository's `compose.yml` owns the `kuber-system` namespace, the
113
+ `kuber-server` image, its Deployment, Service, and Ingress. `.kuberrc.ts`
114
+ extends the rendered manifests with the server's ServiceAccount and RBAC:
115
+
116
+ - a namespaced `Role`/`RoleBinding` (`kuber-server-auth`) for the `kuber-system`
117
+ Secrets, ConfigMaps, Pods, Jobs, and Leases the server itself reads and writes
118
+ - a `ClusterRole`/`ClusterRoleBinding` (`kuber-server-manager`) granting the
119
+ cross-namespace verbs it needs to manage user projects
120
+
121
+ `compose.yml` runs the server as a non-root user (`runAsUser`/`runAsGroup`
122
+ `1000`), drops all Linux capabilities, uses a read-only root filesystem with a
123
+ `RuntimeDefault` seccomp profile, and backs `/data` with a Longhorn PVC
124
+ (`kuber-build-data`). The PVC hosts the CAS, materialized workspaces, and
125
+ resumable upload bytes, and is shared with BuildKit Jobs.
126
+
127
+ Deploy the server with kuber itself:
128
+
129
+ ```bash
130
+ KUBER_BOOTSTRAP_PASSWORD='replace-me' kuber up
131
+ ```
132
+
133
+ The server creates an `admin` user (from `KUBER_BOOTSTRAP_USERNAME`, default
134
+ `dmgnr`) on first boot only if that user does not already exist. The bootstrap
135
+ Secret is only rendered while `KUBER_BOOTSTRAP_PASSWORD` is set. After logging
136
+ in successfully, reconcile without that variable and restart once so kuber
137
+ removes the stale bootstrap Secret and the password leaves the pod environment:
138
+
139
+ ```bash
140
+ kuber up --no-b
141
+ kuber restart kuber-server
142
+ ```
143
+
144
+ See [Account Recovery](#account-recovery) for what to do if you are locked out.
145
+
146
+ ### Security Constraints
147
+
148
+ Because the server's ServiceAccount is scoped by the RBAC in `.kuberrc.ts`, it
149
+ can only act on the resources kuber manages. The management layer additionally
150
+ enforces ownership, so the server refuses to mutate resources that do not carry
151
+ kuber's workspace labels. Deleting a resource requires its UID as an optimistic
152
+ concurrency precondition, and workspace deletion also requires the namespace
153
+ UID. Namespaces that are not owned by kuber, or owned by a different workspace,
154
+ are never mutated (see [Workspaces and Migration](#workspaces-and-migration)).
155
+
156
+ ## Workspaces and Migration
157
+
158
+ Each project maps to a Kubernetes namespace derived from the current working
159
+ directory. The CLI records a "workspace" on the server keyed by project name and
160
+ borrows the namespace UID to guarantee it owns the namespace before reconciling.
161
+ Adopting existing resources relabels them (Server-Side Apply) only when they are
162
+ already managed by kuber and not owned by another workspace.
163
+
164
+ `up` refuses to mutate a namespace when:
165
+
166
+ - the namespace already exists but does not carry kuber's managed-by label
167
+ (`external`), or
168
+ - the namespace is labeled for a different workspace UID
169
+ (`different-workspace`)
170
+
171
+ In those cases the CLI prints a hint to `POST
172
+ /workspaces/<project>/adopt` with the namespace UID to complete a safe,
173
+ explicit adoption. The platform namespace (`kuber-system`) is adopted through
174
+ the admin-only platform adoption route.
175
+
176
+ Workspace state, revisions, operations, and audit events are stored as Secrets
177
+ and ConfigMaps in `kuber-system` keyed by kuber's `kuber.astrxl.dev/type`
178
+ label. Workspace updates are optimistic (If-Match on resource version) and
179
+ immutable revisions are recorded so history survives. Expired sessions are
180
+ cleaned up on an interval, and stale operations are marked failed on server
181
+ startup recovery.
182
+
183
+ ### Migration Behavior
184
+
185
+ `kuber up` is idempotent and safe to re-run. Each run:
186
+
187
+ 1. snapshots the workspace and uploads missing blobs to the server CAS,
188
+ 2. ensures the workspace record (creating or updating it with an optimistic
189
+ If-Match),
190
+ 3. adopts the namespace and its kuber-managed resources,
191
+ 4. reconciles managed Postgres and S3 claims,
192
+ 5. renders manifests, plans the diff, applies desired resources, waits for
193
+ rollout, and deletes stale resources.
194
+
195
+ Because resource references are immutable digests, re-running `up` only restarts
196
+ deployments whose image content actually changed. `start` re-resolves published
197
+ digests without building.
198
+
199
+ ## Registry Authentication
200
+
201
+ Building and pushing images from inside the cluster typically requires
202
+ credentials for the target registry. These are read from a Docker config file
203
+ (`KUBER_REGISTRY_CONFIG`, default `/etc/kuber/registry/config.json`) and mounted
204
+ as an image pull secret named by `KUBER_REGISTRY_SECRET`.
205
+
206
+ Registry authentication is optional. If `KUBER_REGISTRY_SECRET` is unset, the
207
+ server warns at startup and BuildKit uses anonymous registry access. This is
208
+ intended for registries that allow anonymous push/pull. The same credentials are
209
+ used when the server resolves a published image digest (`start` / `export`).
210
+
211
+ The server supports both standard registry bearer-token (`WWW-Authenticate:
212
+ Bearer`) and pre-emptive Basic auth when resolving digests.
213
+
214
+ ### Build Registry Environment
215
+
216
+ The server's registry behavior is driven by a few closely related environment
217
+ variables:
218
+
219
+ - `KUBER_BUILD_REGISTRY` (default `registry.neko-piranha.ts.net`): the registry
220
+ the in-cluster BuildKit pushes built images to and `kuber` uses as the image
221
+ namespace. It also supplies the registry host for authentication.
222
+ - `KUBER_INTERNAL_REGISTRY_HOST`: the host of an internal registry (for example
223
+ the in-cluster distribution service) used for the BuildKit cache image and,
224
+ when set, the image direct push target. When unset, push and cache fall back
225
+ to `KUBER_BUILD_REGISTRY`.
226
+ - `KUBER_INTERNAL_REGISTRY_INSECURE`: set to `"true"` to push to the internal
227
+ registry over plain HTTP instead of HTTPS. Only meaningful when
228
+ `KUBER_INTERNAL_REGISTRY_HOST` is set.
229
+ - `KUBER_PUSH_IMAGE_PREFIX` (default `kuber/`): a prefix applied to project
230
+ images pushed to the internal registry when `KUBER_INTERNAL_REGISTRY_HOST` is
231
+ configured.
232
+ - `KUBER_REGISTRY_RESOLVE_ORIGIN`: an explicit origin used to resolve a
233
+ published image digest (used by `start` / `export`). Useful when the digest
234
+ must be resolved from a different endpoint than the build/push registry, such
235
+ as an internal HTTP registry.
236
+ - `KUBER_REGISTRY_CONFIG`: path to the Docker config file with registry
237
+ credentials (see above).
238
+ - `KUBER_REGISTRY_SECRET`: the image pull Secret mounted for BuildKit's
239
+ registry access.
240
+ - `KUBER_BUILDKIT_IMAGE`: override the BuildKit runner image used for builds.
241
+ - `KUBER_BUILD_DATA_CLAIM`: the PVC claim backing builds.
242
+
243
+ See [Server Deployment](#server-deployment) for how `compose.yml` wires the
244
+ internal registry variables for the kuber-server pod.
245
+
246
+ ## Account Recovery
247
+
248
+ If you lose your credentials and cannot log in:
249
+
250
+ 1. Recreate the bootstrap admin by deploying with
251
+ `KUBER_BOOTSTRAP_PASSWORD` set again:
252
+ ```bash
253
+ KUBER_BOOTSTRAP_PASSWORD='new-password' kuber up --no-b
254
+ kuber restart kuber-server
255
+ ```
256
+ 2. The server only creates the bootstrap user if the account does not already
257
+ exist, so a fresh `kuber login <username>` with the new password works, or
258
+ use the newly created admin to reset other accounts:
259
+ ```bash
260
+ kuber users update <username> --password
261
+ ```
262
+ 3. After recovering, reconcile without `KUBER_BOOTSTRAP_PASSWORD` and restart
263
+ so the bootstrap Secret is removed and the password leaves the pod
264
+ environment.
265
+
266
+ Because user records and session hashes are stored as Secrets in
267
+ `kuber-system`, recovery relies on cluster administrators being able to redeploy
268
+ the server with bootstrap credentials. `users revoke <username>` forcibly logs a
269
+ user out across all devices.
39
270
 
40
271
  ## Next.js Example
41
272
 
42
- [`example/`](example/) contains a documented deployment template for adding kuber to an existing Bun-powered Next.js project without initializing or bundling an application in this repository. It includes a standalone-output Dockerfile, `.dockerignore`, `compose.yml`, and the required Next.js configuration.
273
+ [`example/`](example/) contains a documented deployment template for adding
274
+ kuber to an existing Bun-powered Next.js project without initializing or
275
+ bundling an application in this repository. It includes a standalone-output
276
+ Dockerfile, `.dockerignore`, `compose.yml`, and the required Next.js
277
+ configuration.
43
278
 
44
279
  ## Running
45
280
 
@@ -72,16 +307,23 @@ bun run index.ts db ls
72
307
  bun run index.ts s3 ls
73
308
  bun run index.ts s3 creds app
74
309
  bun run index.ts s3 ui app
310
+ bun run index.ts login dmgnr
311
+ bun run index.ts users ls
312
+ bun run index.ts operations ls
313
+ bun run index.ts audit ls
75
314
  ```
76
315
 
77
316
  All commands accept `--config` to use a configuration file other than
78
317
  `.kuberrc.ts`:
79
318
 
80
319
  ```bash
81
- kuber --config deploy/production.kuberrc.ts up
82
- kuber up --config deploy/production.kuberrc.ts
320
+ kuber --config production.kuberrc.ts up
321
+ kuber up --config production.kuberrc.ts
83
322
  ```
84
323
 
324
+ `login`, `logout`, and `whoami` are context-free and do not require a
325
+ configuration.
326
+
85
327
  ### Shell Completion
86
328
 
87
329
  Generate and load completions for your shell:
@@ -91,27 +333,44 @@ source <(kuber complete zsh)
91
333
  source <(kuber complete bash)
92
334
  ```
93
335
 
94
- For a permanent setup, write the generated script to a file and source it from your shell configuration. Fish and PowerShell are also supported through `kuber complete fish` and `kuber complete powershell`.
336
+ For a permanent setup, write the generated script to a file and source it from
337
+ your shell configuration. Fish and PowerShell are also supported through
338
+ `kuber complete fish` and `kuber complete powershell`.
95
339
 
96
340
  ## Commands
97
341
 
98
- - `up`: build images if needed, pin matching registry digests, render manifests, apply them, and wait for rollout
99
- - `start`: same as `up` but resolves the currently published image digests instead of building
100
- - `stop`: scale managed deployments to zero
101
- - `restart`: roll out a restart across managed deployments
102
- - `rollback` (alias `fuck`): roll one deployment back to its previous release, or all managed deployments when no name is given
103
- - `down`: delete managed resources while keeping ingress, PVCs, managed databases, and managed S3 storage
104
- - `down -f`: also delete ingress, PVCs, managed database and S3 resources, and the namespace
105
- - `ps`: print an ANSI tree of the current project namespace, hiding stopped deployments by default
106
- - `ps -a`: include stopped deployments and stale ReplicaSets in the tree
107
- - `logs [deployment]`: print logs for one deployment or all managed deployments
108
- - `logs -f [deployment]`: follow logs continuously
109
- - `exec <deployment> <command...>`: execute a command inside a running deployment pod
110
- - `db ls`: list managed Postgres claims declared in the current Compose file
111
- - `db creds <service>`: print the generated connection details for a managed Postgres claim
112
- - `s3 ls`: list managed S3 claims declared in the current Compose file
113
- - `s3 creds <service>`: print all generated S3 environment variables for a service
114
- - `s3 ui <service>`: print the Garage UI object-browser URL for a service bucket
342
+ - `up [--no-b]`: build images if needed, ensure the workspace, reconcile managed
343
+ Postgres/S3, render manifests, apply them, wait for rollout, and delete stale
344
+ resources
345
+ - `start`: like `up` but re-resolves the currently published image digests
346
+ instead of building
347
+ - `login [username] [--persist]`: authenticate with the kuber API
348
+ - `logout`: revoke and remove the current API session
349
+ - `whoami`: show the authenticated API user and roles
350
+ - `users`: administer user accounts and roles
351
+ - `operations`: inspect server-side reconciliation operations
352
+ - `audit`: inspect the audit trail
353
+ - `ps [-a]`: print an ANSI graph of the current project namespace, hiding stopped
354
+ deployments by default
355
+ - `logs [deployment] [-f]`: print (or follow) logs for one deployment or all
356
+ managed deployments
357
+ - `exec <deployment> <command...>`: execute a command inside a running
358
+ deployment pod over an interactive WebSocket
359
+ - `restart [deployment]`: roll out a restart across managed deployments
360
+ - `stop`: delete the matching name-scoped HPAs (so autoscaling cannot scale
361
+ replicas back up) and scale managed deployments to zero
362
+ - `rollback` (alias `fuck`) `[deployment]`: roll one deployment back to its
363
+ previous release, or all managed deployments when no name is given
364
+ - `down [-f]`: delete managed resources while keeping ingress, PVCs, managed
365
+ databases, and managed S3 storage; `-f` also deletes those and the namespace
366
+ - `db ls` / `db creds <service>`: list or print credentials for managed Postgres
367
+ claims
368
+ - `s3 ls` / `s3 creds <service>` / `s3 ui <service>`: list managed S3 claims,
369
+ print their credentials, or print the Garage UI URL for a bucket
370
+ - `export [-o file]`: render manifests to a YAML file without applying them
371
+
372
+ Lifecycle commands (`restart`, `stop`, `rollback`, `down`) are idempotent:
373
+ identical requests are deduplicated server-side and tracked as operations.
115
374
 
116
375
  ## Configuration
117
376
 
@@ -125,11 +384,6 @@ export default {
125
384
  project: "my-app",
126
385
  composeFile: "compose.production.yml",
127
386
  registry: "registry.example.com",
128
- builders: {
129
- amd64: "kuber@amd-builder",
130
- arm64: "kuber@arm-builder",
131
- remoteRoot: "kuber-build",
132
- },
133
387
  rolloutTimeoutMs: 10 * 60_000,
134
388
 
135
389
  async compose(compose) {
@@ -147,37 +401,50 @@ export default {
147
401
 
148
402
  Operational defaults:
149
403
 
150
- - `project`: Compose top-level `name`, falling back to the current working directory name
404
+ - `project`: Compose top-level `name`, falling back to the current working
405
+ directory name
151
406
  - `composeFile`: the first recognized Compose filename in the working directory
152
407
  - `registry`: `registry.neko-piranha.ts.net`
153
- - `builders.amd64`: `kuber@astral-th`
154
- - `builders.arm64`: `kuber@astral`
155
- - `builders.remoteRoot`: `kuber-build`
156
408
  - `rolloutTimeoutMs`: `300000`
157
409
 
158
410
  Project-name precedence is `.kuberrc.ts project`, Compose top-level `name`, then
159
411
  the current working directory name.
160
412
 
413
+ The `registry` value controls which registry the CLI requests build images from
414
+ and which the server uses to resolve published digests. `rolloutTimeoutMs`
415
+ bounds how long `up` and `rollback` wait for a Deployment rollout.
416
+
161
417
  Configuration hooks can be synchronous or asynchronous and receive mutable
162
418
  values:
163
419
 
164
- - `compose(compose, context)`: once after parsing and validation; affects every command that reads Compose
165
- - `preBuild(compose, context)`: before build eligibility is evaluated when builds are enabled
166
- - `postBuild(result, context)`: after images are built; `result` contains `built` and `changed` service names
167
- - `postRender(resources, context)`: after rendering and before reconciliation planning; also runs for `export`
168
- - `postApply(resources, context)`: after desired resources are successfully applied
420
+ - `compose(compose, context)`: once after parsing and validation; affects every
421
+ command that reads Compose
422
+ - `preBuild(compose, context)`: before build eligibility is evaluated when
423
+ builds are enabled
424
+ - `postBuild(result, context)`: after images are built; `result` contains
425
+ `built` and `changed` service names
426
+ - `postRender(resources, context)`: after rendering and before reconciliation
427
+ planning; also runs for `export`
428
+ - `postApply(resources, context)`: after desired resources are successfully
429
+ applied
169
430
 
170
431
  Hook context contains the resolved `cwd`, `project`, `composeFile`, and optional
171
432
  `configFile`. A hook error aborts the command and is reported by the normal CLI
172
433
  error handler.
173
434
 
435
+ Registry and rollout configuration remain part of the CLI-facing contract.
436
+ Image builder selection is not configurable: builds are scheduled, executed, and
437
+ owned entirely by the server.
438
+
174
439
  ## Compose Conventions
175
440
 
176
- `kuber` supports a few project-specific Compose conventions on top of normal service translation.
441
+ `kuber` supports a few project-specific Compose conventions on top of normal
442
+ service translation.
177
443
 
178
444
  ### Host-Based Ports
179
445
 
180
- If a `ports` entry uses a hostname instead of a numeric published port, `kuber` treats it as an ingress host and routes traffic to the target container port.
446
+ If a `ports` entry uses a hostname instead of a numeric published port, `kuber`
447
+ treats it as an ingress host and routes traffic to the target container port.
181
448
 
182
449
  Example:
183
450
 
@@ -188,9 +455,11 @@ services:
188
455
  - somedomain.astrxl.dev:3000
189
456
  ```
190
457
 
191
- That produces a Kubernetes `Ingress` rule for `somedomain.astrxl.dev` pointing at the service port for container port `3000`.
458
+ That produces a Kubernetes `Ingress` rule for `somedomain.astrxl.dev` pointing
459
+ at the service port for container port `3000`.
192
460
 
193
- Single-level wildcard subdomains are supported. Quote wildcard entries so YAML does not treat the leading `*` as an alias:
461
+ Single-level wildcard subdomains are supported. Quote wildcard entries so YAML
462
+ does not treat the leading `*` as an alias:
194
463
 
195
464
  ```yml
196
465
  services:
@@ -200,7 +469,8 @@ services:
200
469
  - "*.secure.astrxl.dev:3001:protected"
201
470
  ```
202
471
 
203
- Protected routes use the kuber dialect and render Traefik `IngressRoute` resources instead of plain Kubernetes `Ingress`:
472
+ Protected routes use the kuber dialect and render Traefik `IngressRoute`
473
+ resources instead of plain Kubernetes `Ingress`:
204
474
 
205
475
  ```yml
206
476
  services:
@@ -216,6 +486,39 @@ Translation rules:
216
486
  - `host:port:protected` -> Traefik `IngressRoute` with middleware `routing/cf-auth` and host-wide matching
217
487
  - `host:port:protected(path1,path2,...)` -> Traefik `IngressRoute` with middleware `routing/cf-auth` and explicit `PathPrefix(...)` matches only
218
488
 
489
+ ### Replicas and Autoscaling
490
+
491
+ `deploy.replicas` (or the top-level `scale` field) controls the Deployment
492
+ replica count. A plain integer or numeric string renders a fixed `replicas`
493
+ value, with `scale` taking precedence over `deploy.replicas`.
494
+
495
+ A `"min-max"` range string requests autoscaling instead of a fixed count:
496
+
497
+ ```yml
498
+ services:
499
+ app:
500
+ image: app
501
+ deploy:
502
+ replicas: "2-6"
503
+ ```
504
+
505
+ kuber renders:
506
+
507
+ - a `Deployment` with `replicas` set to the range minimum (`2`)
508
+ - a `HorizontalPodAutoscaler` (`autoscaling/v2`) targeting that Deployment,
509
+ with `minReplicas: 2`, `maxReplicas: 6`, and a CPU target of 80%
510
+ utilization
511
+ - a `100m` CPU request injected into the container, unless `x-container`
512
+ already specifies a CPU request (the HPA needs a CPU request to scale on)
513
+
514
+ For both fixed counts above one and autoscaled ranges whose maximum exceeds
515
+ one, kuber also adds a `topologySpreadConstraints` entry spreading pods across
516
+ hosts (`kubernetes.io/hostname`, `maxSkew: 1`,
517
+ `whenUnsatisfiable: ScheduleAnyway`).
518
+
519
+ Malformed non-numeric replica values (for example `"lots"`) are rejected with
520
+ a clear error instead of silently defaulting.
521
+
219
522
  ### Managed Postgres
220
523
 
221
524
  You can declare a managed Postgres database with a pseudo-volume:
@@ -236,7 +539,12 @@ services:
236
539
  - postgresql:user/database
237
540
  ```
238
541
 
239
- This creates or reuses the managed CNPG role secret, reconciles the database resource, and injects `DATABASE_URL` into the generated app secret in Kubernetes.
542
+ This creates or reuses the managed CNPG role secret, reconciles the database
543
+ resource, and injects `DATABASE_URL` and
544
+ `REDIS_URL=redis://redis.database.svc.cluster.local` into the generated app
545
+ secret in Kubernetes. `REDIS_URL` is only added to services with a managed
546
+ Postgres claim. Running `kuber db creds <service>` performs the same focused
547
+ Secret, role, and Database reconciliation before printing credentials.
240
548
 
241
549
  ### Managed S3
242
550
 
@@ -249,7 +557,8 @@ services:
249
557
  - s3:app
250
558
  ```
251
559
 
252
- This creates `GarageBucket/app` and `GarageKey/app` in `garage-system`. To use different key and bucket names:
560
+ This creates `GarageBucket/app` and `GarageKey/app` in `garage-system`. To use
561
+ different key and bucket names:
253
562
 
254
563
  ```yml
255
564
  services:
@@ -258,7 +567,8 @@ services:
258
567
  - s3:app-key/shared-assets
259
568
  ```
260
569
 
261
- The Garage operator generates the credentials. `kuber` reads its generated Secret and injects these values into the service's `<service>-env` Secret:
570
+ The Garage operator generates the credentials. `kuber` reads its generated
571
+ Secret and injects these values into the service's `<service>-env` Secret:
262
572
 
263
573
  - `AWS_ACCESS_KEY_ID`
264
574
  - `AWS_SECRET_ACCESS_KEY`
@@ -266,7 +576,9 @@ The Garage operator generates the credentials. `kuber` reads its generated Secre
266
576
  - `AWS_REGION`
267
577
  - `S3_BUCKET`
268
578
 
269
- One service can declare both `postgresql:...` and `s3:...`; all generated values are merged into the same service Secret. Managed Garage buckets and keys are retained by normal `down` and deleted by `down -f`.
579
+ One service can declare both `postgresql:...` and `s3:...`; all generated
580
+ values are merged into the same service Secret. Managed Garage buckets and keys
581
+ are retained by normal `down` and deleted by `down -f`.
270
582
 
271
583
  Inspect a claim, print its generated credentials, or get its Garage UI URL:
272
584
 
@@ -282,9 +594,11 @@ assignments. `s3 ui` only prints the URL; it does not open a browser.
282
594
 
283
595
  ### Environment Files
284
596
 
285
- `env_file` entries are read locally and turned into a Kubernetes `Secret` named `<service>-env`. Deployments then consume that secret through `envFrom`.
597
+ `env_file` entries are read locally and turned into a Kubernetes `Secret` named
598
+ `<service>-env`. Deployments then consume that secret through `envFrom`.
286
599
 
287
- This is also where generated values such as `DATABASE_URL` and the managed S3 environment are injected.
600
+ This is also where generated values such as `DATABASE_URL` and the managed S3
601
+ environment are injected.
288
602
 
289
603
  ### Volumes
290
604
 
@@ -297,7 +611,11 @@ This is also where generated values such as `DATABASE_URL` and the managed S3 en
297
611
  - `postgresql:...` is treated as a managed database claim, not as a filesystem mount
298
612
  - `s3:...` is treated as a managed object-storage claim, not as a filesystem mount
299
613
 
300
- Named volumes also support kuber-specific storage hints.
614
+ Named volumes also support kuber-specific Longhorn storage hints. Kuber renders
615
+ each distinct placement policy as a deterministic, reusable Longhorn
616
+ `StorageClass`, then references that class from the PVC. The generated class
617
+ uses Longhorn's `numberOfReplicas`, `diskSelector`, and `dataLocality`
618
+ parameters; placement fields are never written directly to the PVC.
301
619
 
302
620
  Default behavior:
303
621
 
@@ -329,8 +647,8 @@ services:
329
647
  Meaning:
330
648
 
331
649
  - `name(20Gi):/path` -> PVC size `20Gi`
332
- - `name(20Gi on archive):/path` -> PVC size `20Gi`, `diskTag: ["archive"]`, `dataLocality: "none"`
333
- - `name(20Gi on 1 archive):/path` -> PVC size `20Gi`, `replicaCount: 1`, `diskTag: ["archive"]`, `dataLocality: "none"`
650
+ - `name(20Gi on archive):/path` -> PVC size `20Gi`, with a StorageClass using `diskSelector: "archive"` and disabled data locality
651
+ - `name(20Gi on 1 archive):/path` -> PVC size `20Gi`, with a StorageClass using one replica and `diskSelector: "archive"`
334
652
 
335
653
  Explicit extensions are also supported.
336
654
 
@@ -368,14 +686,32 @@ Precedence:
368
686
  - top-level `volumes.<name>.x-*`
369
687
  - fallback default `1Gi on 2 fast`
370
688
 
689
+ StorageClasses are cluster-scoped and content-addressed by policy. They are
690
+ shared across projects and intentionally retained when a project is removed.
691
+
371
692
  ## Building
372
693
 
373
- Image builds run through the selected SSH builder. After a push, the registry's
374
- manifest digest is captured and embedded into the rendered Deployment as an
375
- immutable `:latest@sha256:...` reference, so a changed image naturally triggers
376
- a rollout (there is no separate "restart changed deployments" step). `start` and
377
- `export` look up the currently published digest without rebuilding, and fail if
378
- a buildable service has no published image yet.
694
+ When a service declares `build`, the CLI:
695
+
696
+ 1. snapshots the repository into a content-addressed workspace
697
+ (committed git state, tracked changes, untracked files, and ignored `.env*`
698
+ files),
699
+ 2. negotiates with the server and uploads only the blobs it is missing,
700
+ 3. submits a build request; the server materializes the workspace from the CAS
701
+ onto a shared RWX PVC and runs a rootless BuildKit Job that builds and pushes
702
+ the configured image with registry cache.
703
+
704
+ Build containers are restricted: they run as non-root (`runAsUser`/`runAsGroup`
705
+ `1000`), do not mount the service account token, and only read the workspace
706
+ (read-only mount) and a writable BuildKit state `emptyDir`. After a push, the
707
+ registry's manifest digest is captured and embedded into the rendered Deployment
708
+ as an immutable `:latest@sha256:...` reference, so a changed image naturally
709
+ triggers a rollout. `start` and `export` look up the currently published digest
710
+ without rebuilding, and fail if a buildable service has no published image yet.
711
+
712
+ `export` is side-effect-free: it refuses to render managed Postgres or S3 claims
713
+ (because it cannot call the server to generate credentials) and instead tells
714
+ you to run `kuber up` or remove the managed provider claims.
379
715
 
380
716
  ## Rollback
381
717
 
@@ -395,23 +731,35 @@ Notes and limitations:
395
731
  - A later `kuber up` or `kuber start` re-resolves `:latest` and returns the
396
732
  Deployment to the current desired state anyway, so rollback is the right tool
397
733
  for responding to a bad deploy, not for permanently pinning an old version.
398
- - Rollback only considers Deployments managed by kuber (`app.kubernetes.io/managed-by=kuber`).
734
+ - Rollback only considers Deployments managed by kuber
735
+ (`app.kubernetes.io/managed-by=kuber`) and only runs within a workspace whose
736
+ namespace kuber owns.
399
737
 
400
- To build distributable binaries:
738
+ ## Workspace State and Operations
401
739
 
402
- ```bash
403
- bun run build.ts
404
- ```
740
+ The server persists per-workspace state, revisions, operations, and audit
741
+ events as Secrets/ConfigMaps in `kuber-system`. Mutations are idempotent:
742
+
743
+ - operations carry an idempotency key so retries do not double-apply,
744
+ - resource deletion requires a UID precondition,
745
+ - workspace replacement is guarded by If-Match.
405
746
 
406
- If you only want a plain JavaScript bundle for quick local use in another workspace, build `index.ts` directly:
747
+ The CLI is built with the normal `build` script for distribution and local
748
+ use:
407
749
 
408
750
  ```bash
409
- bun build index.ts --target bun --minify --sourcemap --outdir dist
751
+ bun run build
410
752
  ```
411
753
 
412
754
  ## Notes
413
755
 
414
756
  - Resource names and namespaces are derived from the current working directory.
415
- - The remote build flow is optimized for local iteration, not for producing a perfectly clean export of the repository.
416
- - Managed database support is Kubernetes-only. It injects `DATABASE_URL` into the generated app secret and does not rewrite local `.env` files.
417
- - `kuber` operates on managed resources in the namespace matching the current directory name.
757
+ - The workspace snapshot is optimized for local iteration, not for producing a
758
+ perfectly clean export of the repository.
759
+ - Managed database support is Kubernetes-only. It injects `DATABASE_URL` into
760
+ the generated app secret and does not rewrite local `.env` files.
761
+ - `kuber` operates on managed resources in the namespace matching the current
762
+ directory name.
763
+ - Builds are scheduled, executed, and owned by the server. There is no local
764
+ SSH/daemon builder configuration; `registry` and `rolloutTimeoutMs` remain
765
+ CLI-facing settings.