a73x

fd49ad63

feat: eitri.sh runs eitri — the hosted control plane

a73x   2026-07-29 18:10

Commit message
feat: eitri.sh runs eitri — the hosted control plane

One eitri-server container serves console, API, OIDC auth, QUIC sync,
and the SSH gate for console.eitri.sh, pinned to the cluster's public-IP
node with hostNetwork sockets (8443/udp sync, 2222/tcp gate — both flows
carry pinned end-to-end cryptography and tolerate no middlebox; the
console listens on :8081 behind the Service). deploy/server/ carries the
whole shape: local-path PVC for state, server.json as a Secret,
cert-manager + IngressRoute behind Cloudflare DNS, nightly on-PVC sqlite
backups with an off-node pull script, and the operator runbook including
host re-homing (a host from another fleet drops its snapshot epoch).
scripts/server-image.sh ships the SPA-embedded static binary on
distroless arm64. The site links Sign in on every page, and the
quickstart opens with the hosted way: create an account, join a host,
boot a VM, SSH in through gate.eitri.sh.

Makefile
Old New
@@ -13,7 +13,7 @@ LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret
13 .PHONY: build build-go web test vet proto clean \ 13 .PHONY: build build-go web test vet proto clean \
14 lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check \ 14 lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check \
15 site site-check ci deadcode \ 15 site site-check ci deadcode \
16 deploy release hooks site-image 16 deploy release hooks site-image server-image
17 17
18 # Enable the repo's client-side merge gate: point git at .githooks, whose 18 # Enable the repo's client-side merge gate: point git at .githooks, whose
19 # pre-push hook runs `make ci` before any push that updates main. Run once per 19 # pre-push hook runs `make ci` before any push that updates main. Run once per
@@ -161,6 +161,10 @@ site-image:
161 $(MAKE) site SITE_DIST=dist/$(VERSION) 161 $(MAKE) site SITE_DIST=dist/$(VERSION)
162 ./scripts/site-image.sh 162 ./scripts/site-image.sh
163 163
164 # eitri-server production image (arm64) — the hosted control plane.
165 server-image: web
166 ./scripts/server-image.sh
167
164 # Whole-program dead-code gate: fails on any function unreachable from a real 168 # Whole-program dead-code gate: fails on any function unreachable from a real
165 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what 169 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what
166 # catches production code kept alive only by its own tests; the fix is to remove 170 # catches production code kept alive only by its own tests; the fix is to remove
deploy/server/Dockerfile
Old New
@@ -0,0 +1,7 @@
1 # eitri-server production image: one static Go binary (pure-Go sqlite, no
2 # CGO) on distroless/static — CA roots included for OIDC discovery, runs as
3 # nonroot (uid 65532). The console SPA is embedded in the binary; state
4 # lives on the mounted PVC and config in the mounted server.json Secret.
5 FROM gcr.io/distroless/static-debian12:nonroot
6 COPY eitri-server /eitri-server
7 ENTRYPOINT ["/eitri-server"]
deploy/server/README.md
Old New
@@ -0,0 +1,81 @@
1 # eitri-server on the eitri.sh cluster
2
3 One container runs the whole control plane (embedded console, API, OIDC
4 auth, QUIC sync, SSH gate), pinned to vnic-1 — the cluster's public-IP
5 node — with state on a local-path PVC and config from a Secret.
6
7 ## One-time bring-up
8
9 1. **Google OAuth client** (GCP console → Credentials): Web application,
10 authorized redirect `https://console.eitri.sh/auth/callback`. Note the
11 client id + secret.
12 2. **server.json** (never committed; lives only in the Secret):
13
14 ```json
15 {
16 "db_path": "/var/lib/eitri/eitri.db",
17 "http_listen": ":8081",
18 "quic_listen": ":8443",
19 "default_image_url": "https://cloud-images.ubuntu.com/resolute/20260720/resolute-server-cloudimg-amd64.img",
20 "default_image_sha256": "117816726abbdefc5ef3e38902e81a76f1c76c3610e709999d0885f9d5d9b477",
21 "advertise_http": "https://console.eitri.sh",
22 "advertise_quic": "sync.eitri.sh:8443",
23 "cidr_pool": "10.78.0.0/16",
24 "ssh_listen": ":2222",
25 "ssh_gate_domain": "gate.eitri.sh",
26 "ssh_ca_key": "/var/lib/eitri/ssh_ca",
27 "ssh_host_key": "/var/lib/eitri/ssh_host_key",
28 "host_secret": "<openssl rand -hex 32>",
29 "oidc": {
30 "issuer": "https://accounts.google.com",
31 "client_id": "<google client id>",
32 "client_secret": "<google client secret>",
33 "public_url": "https://console.eitri.sh"
34 }
35 }
36 ```
37
38 ```
39 kubectl -n eitri create secret generic eitri-server-config \
40 --from-file=server.json=./server.json
41 ```
42
43 The pool is deliberately 10.78/16 — the dev fleet is 10.77/16, so a
44 host flicked between fleets never carries colliding guest subnets.
45 3. **Image**: `make server-image` (needs SERVER_IMAGE in deploy.env), then
46 `kubectl -n eitri set image deployment/eitri-server eitri-server=<SERVER_IMAGE>:<version>`.
47 4. **Apply**: `kubectl apply -f pvc.yaml -f deployment.yaml -f service.yaml -f certificate.yaml -f ingressroute.yaml -f backup-cronjob.yaml`. The server creates its own SSH CA and gate host key on the PVC on first boot, so the gate comes up on its own — no key step is required.
48 5. **Network** (operator):
49 - Oracle security list AND vnic-1 host firewall: open 8443/udp, 2222/tcp.
50 - Cloudflare DNS: `console.eitri.sh` proxied to the eitri.sh origin (repoint off the old tailnet record — confirm nothing dev-side still resolves it); `sync.eitri.sh` and `gate.eitri.sh` grey-cloud A records to vnic-1's public IP.
51 6. **Backups**: nightly CronJob writes dated sqlite backups on the PVC; run `backup-pull.sh` from cron on an off-cluster machine — local-path storage does not survive the node, the off-node copy is the DR story.
52
53 ## Rollout
54
55 make server-image
56 kubectl -n eitri set image deployment/eitri-server eitri-server=<SERVER_IMAGE>:<version>
57
58 Single replica + Recreate: a rollout is a short outage; agents reconnect
59 and re-sync on their own (the sync registry is rebuilt from Hellos).
60
61 The pod runs hostNetwork: the QUIC sync socket (8443/udp) and SSH gate
62 (2222/tcp) bind vnic-1's interfaces directly — both flows carry pinned
63 end-to-end cryptography and tolerate no middlebox, including the CNI's
64 hostPort NAT, which conntrack-drops long-lived single-tuple UDP flows.
65 The console listens on :8081 (svclb claims host 8080); the Service maps
66 8080 → 8081 so the IngressRoute is unaffected.
67
68 ## Re-homing a host from another fleet
69
70 A host that previously belonged to a different control plane carries that
71 fleet's snapshot epoch in its state dir and will refuse the new server's
72 lower-numbered snapshots ("epoch fence violation" in the server log).
73 After `eitri-agent join` against the new fleet:
74
75 sudo systemctl stop eitri-agent
76 sudo rm /var/lib/eitri-agent/epoch
77 sudo systemctl start eitri-agent
78
79 VMs from the old fleet are absent from the new fleet's desired state and
80 are reaped through the normal quarantine grace — re-home a host only when
81 its existing guests are disposable.
deploy/server/backup-cronjob.yaml
Old New
@@ -0,0 +1,31 @@
1 apiVersion: batch/v1
2 kind: CronJob
3 metadata:
4 name: eitri-server-backup
5 namespace: eitri
6 spec:
7 schedule: "20 3 * * *"
8 concurrencyPolicy: Forbid
9 jobTemplate:
10 spec:
11 template:
12 spec:
13 restartPolicy: Never
14 nodeSelector:
15 kubernetes.io/hostname: vnic-1
16 containers:
17 - name: backup
18 image: alpine:3.20
19 command: ["/bin/sh", "-c"]
20 args:
21 - |
22 set -e
23 apk add --no-cache sqlite
24 mkdir -p /data/backups
25 sqlite3 /data/eitri.db ".backup /data/backups/eitri-$(date +%F).db"
26 find /data/backups -name 'eitri-*.db' -mtime +14 -delete
27 volumeMounts:
28 - {name: data, mountPath: /data}
29 volumes:
30 - name: data
31 persistentVolumeClaim: {claimName: eitri-server-data}
deploy/server/backup-pull.sh
Old New
@@ -0,0 +1,29 @@
1 #!/usr/bin/env bash
2 # Off-node half of the eitri-server backup: copy the nightly sqlite backups out
3 # of the PVC to this machine. Run from cron on a box with kubectl access; the
4 # PVC is local-path on a single node, so an off-node copy is the only thing that
5 # survives losing that node.
6 # DEST defaults to ~/eitri-backups; override: DEST=/mnt/backups ./backup-pull.sh
7 #
8 # The server image is distroless (no tar/shell), so `kubectl cp` against the
9 # server pod cannot work. Instead we run a short-lived alpine helper pinned to
10 # the same node, mounting the same PVC (local-path RWO permits co-mounting on
11 # one node), and stream the backups dir out through it.
12 set -euo pipefail
13 DEST="${DEST:-$HOME/eitri-backups}"
14 NS=eitri
15 POD=eitri-backup-pull
16 mkdir -p "$DEST"
17
18 cleanup() { kubectl -n "$NS" delete pod "$POD" --ignore-not-found --wait=false >/dev/null 2>&1 || true; }
19 trap cleanup EXIT
20
21 kubectl -n "$NS" delete pod "$POD" --ignore-not-found >/dev/null 2>&1 || true
22 kubectl -n "$NS" run "$POD" --image=alpine:3.20 --restart=Never \
23 --overrides='{"spec":{"nodeSelector":{"kubernetes.io/hostname":"vnic-1"},"containers":[{"name":"'"$POD"'","image":"alpine:3.20","command":["sleep","3600"],"volumeMounts":[{"name":"data","mountPath":"/data","readOnly":true}]}],"volumes":[{"name":"data","persistentVolumeClaim":{"claimName":"eitri-server-data"}}]}}' \
24 >/dev/null
25 kubectl -n "$NS" wait --for=condition=Ready "pod/$POD" --timeout=120s >/dev/null
26
27 kubectl -n "$NS" exec "$POD" -- tar cf - -C /data backups | tar xf - -C "$DEST"
28 find "$DEST" -name 'eitri-*.db' -mtime +60 -delete
29 echo "backup-pull: $DEST/backups"
deploy/server/certificate.yaml
Old New
@@ -0,0 +1,11 @@
1 apiVersion: cert-manager.io/v1
2 kind: Certificate
3 metadata:
4 name: console-tls
5 namespace: eitri
6 spec:
7 secretName: console-tls
8 dnsNames: [console.eitri.sh]
9 issuerRef:
10 name: letsencrypt-prod
11 kind: ClusterIssuer
deploy/server/deployment.yaml
Old New
@@ -0,0 +1,59 @@
1 apiVersion: apps/v1
2 kind: Deployment
3 metadata:
4 name: eitri-server
5 namespace: eitri
6 spec:
7 replicas: 1
8 strategy:
9 type: Recreate # sqlite + in-memory sync registry: single process
10 selector:
11 matchLabels: {app: eitri-server}
12 template:
13 metadata:
14 labels: {app: eitri-server}
15 spec:
16 # vnic-1 is load-bearing twice over: the only node with a public IP
17 # (sync/gate listeners below), and local-path binds the PVC to the node
18 # the pod first lands on.
19 nodeSelector:
20 kubernetes.io/hostname: vnic-1
21 # hostNetwork: the QUIC sync socket (8443/udp) and SSH gate (2222/tcp)
22 # bind the node's interfaces directly. Both flows carry their own pinned
23 # cryptography end-to-end and tolerate no middlebox — that includes the
24 # CNI's own hostPort NAT, which conntrack-drops long-lived single-tuple
25 # UDP flows. The console listens on :8081 because svclb already claims
26 # host 8080.
27 hostNetwork: true
28 dnsPolicy: ClusterFirstWithHostNet
29 imagePullSecrets:
30 - name: regcred
31 securityContext:
32 fsGroup: 65532 # distroless nonroot; PVC files must be writable
33 containers:
34 - name: eitri-server
35 image: REGISTRY/eitri-server:VERSION # set at rollout: kubectl -n eitri set image ...
36 # Always: a re-cut release reuses its version tag, and podman-pushed
37 # digests differ from the registry's — the tag plus a forced pull is
38 # the one rollout shape that never serves a stale image.
39 imagePullPolicy: Always
40 ports:
41 - {name: http, containerPort: 8081}
42 - {name: sync, containerPort: 8443, protocol: UDP}
43 - {name: gate, containerPort: 2222, protocol: TCP}
44 volumeMounts:
45 - {name: data, mountPath: /var/lib/eitri}
46 - {name: config, mountPath: /etc/eitri, readOnly: true}
47 livenessProbe:
48 httpGet: {path: /livez, port: 8081}
49 periodSeconds: 10
50 readinessProbe:
51 httpGet: {path: /readyz, port: 8081}
52 periodSeconds: 10
53 volumes:
54 - name: data
55 persistentVolumeClaim: {claimName: eitri-server-data}
56 - name: config
57 secret:
58 secretName: eitri-server-config
59 defaultMode: 0400
deploy/server/ingressroute.yaml
Old New
@@ -0,0 +1,29 @@
1 apiVersion: traefik.io/v1alpha1
2 kind: IngressRoute
3 metadata:
4 name: console-https
5 namespace: eitri
6 spec:
7 entryPoints: [websecure]
8 routes:
9 - match: Host(`console.eitri.sh`)
10 kind: Rule
11 services:
12 - {name: eitri-server, port: 8080}
13 tls:
14 secretName: console-tls
15 ---
16 apiVersion: traefik.io/v1alpha1
17 kind: IngressRoute
18 metadata:
19 name: console-http
20 namespace: eitri
21 spec:
22 entryPoints: [web]
23 routes:
24 - match: Host(`console.eitri.sh`)
25 kind: Rule
26 middlewares:
27 - name: redirect-https
28 services:
29 - {name: eitri-server, port: 8080}
deploy/server/pvc.yaml
Old New
@@ -0,0 +1,11 @@
1 apiVersion: v1
2 kind: PersistentVolumeClaim
3 metadata:
4 name: eitri-server-data
5 namespace: eitri
6 spec:
7 accessModes: [ReadWriteOnce]
8 storageClassName: local-path
9 resources:
10 requests:
11 storage: 5Gi
deploy/server/service.yaml
Old New
@@ -0,0 +1,9 @@
1 apiVersion: v1
2 kind: Service
3 metadata:
4 name: eitri-server
5 namespace: eitri
6 spec:
7 selector: {app: eitri-server}
8 ports:
9 - {name: http, port: 8080, targetPort: 8081}
docs/quickstart.md
Old New
@@ -2,10 +2,90 @@
2 2
3 *From nothing to a VM you can SSH into* 3 *From nothing to a VM you can SSH into*
4 4
5 ## The hosted way (console.eitri.sh)
6
7 eitri.sh runs a control plane so you don't have to. You bring the
8 hardware; the console, sync, and SSH gate are already up. No server
9 install at all.
10
11 ### Create an account
12
13 Open <https://console.eitri.sh> and sign in with Google. Your first
14 sign-in creates your tenant.
15
16 ### Join a host
17
18 On the machine that will serve VMs (Linux, KVM — see "What you need"
19 under Self-hosting), download and verify the host bundle:
20
21 ```sh
22 V=v0.0.1
23 curl -fsSLO "https://eitri.sh/dl/$V/eitri_${V}_linux_amd64.tar.gz"
24 curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS"
25 sha256sum -c SHA256SUMS --ignore-missing
26 tar xzf "eitri_${V}_linux_amd64.tar.gz" && cd "eitri_${V}_linux_amd64"
27 ```
28
29 Then click **+ Add host** in the console and run the command it prints
30 from the unpacked bundle — it installs the agent and joins this machine
31 to your tenant:
32
33 ```sh
34 sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent
35 sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service
36 sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console>
37 sudo systemctl daemon-reload
38 sudo systemctl enable --now eitri-agent
39 ```
40
41 The agent dials out — a machine behind NAT needs no open ports. It goes
42 online in the console within seconds.
43
44 ### Boot a VM
45
46 Console → **+ Create VM**, pick your host, create. Watch it boot in the
47 browser serial console.
48
49 ### SSH in
50
51 SSH access uses certificates signed by **your** CA — eitri never holds a
52 user key that can enter your VMs. On your laptop, download the client CLI,
53 then put a personal access token and your tenant handle in the environment:
54
55 ```sh
56 V=v0.0.1
57 curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz" # arm64 laptop → _arm64
58 tar xzf eitri-cli_${V}_*.tar.gz
59 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri
60
61 export EITRI_URL=https://console.eitri.sh
62 export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens
63 export EITRI_TENANT=<your-tenant> # shown on the console's Settings page, under Identity
64 ```
65
66 Register a user CA once — eitri gets the public key, never the private one:
67
68 ```sh
69 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
70 eitri ca upload "$EITRI_TENANT" ~/.ssh/eitri_user_ca.pub
71 ```
72
73 Then SSH in. Unlike self-hosting, the console and the SSH gate are
74 separate names, so set the gate too:
75
76 ```sh
77 export EITRI_GATE=gate.eitri.sh:2222
78 eitri ssh <vm-name>
79 eitri ssh <vm-name> uptime
80 ```
81
82 ## Self-hosting
83
84 Run the whole control plane yourself — everything below is your own box.
5 eitri is three pieces: a server, an agent on every box that runs VMs, and 85 eitri is three pieces: a server, an agent on every box that runs VMs, and
6 your laptop. `192.0.2.10` is the server below. Substitute yours. 86 your laptop. `192.0.2.10` is the server below. Substitute yours.
7 87
8 ## What you need 88 ### What you need
9 89
10 Every VM host needs KVM (`ls -l /dev/kvm`) and `qemu-img` (Debian/Ubuntu: 90 Every VM host needs KVM (`ls -l /dev/kvm`) and `qemu-img` (Debian/Ubuntu:
11 `qemu-utils`, Fedora: `qemu-img`). The agent fetches cloud-hypervisor and the 91 `qemu-utils`, Fedora: `qemu-img`). The agent fetches cloud-hypervisor and the
@@ -36,7 +116,7 @@ sha256sum -c SHA256SUMS --ignore-missing
36 Plain files at stable URLs — no install script. `sha256sum -c` must say OK 116 Plain files at stable URLs — no install script. `sha256sum -c` must say OK
37 for every tarball before you unpack anything. 117 for every tarball before you unpack anything.
38 118
39 ## The server 119 ### The server
40 120
41 ```sh 121 ```sh
42 tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64 122 tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64
@@ -96,7 +176,7 @@ next. `public_url` is where browsers reach the console (the callback lands at
96 `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing 176 `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing
97 your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md). 177 your own IdP instead of the bundled issuer: see [byo-idp.md](byo-idp.md).
98 178
99 ## Sign-in 179 ### Sign-in
100 180
101 The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs 181 The console always signs in through OIDC. The bundled `eitri-oidc` issuer runs
102 next to the server on loopback. Install it, write its config, and add yourself: 182 next to the server on loopback. Install it, write its config, and add yourself:
@@ -149,7 +229,7 @@ issuer stays on loopback.
149 Sign in at `http://192.0.2.10:8080` with the user you added. Your first 229 Sign in at `http://192.0.2.10:8080` with the user you added. Your first
150 sign-in creates your tenant. 230 sign-in creates your tenant.
151 231
152 ## Join a host 232 ### Join a host
153 233
154 Once per box that runs VMs. The server's box counts. 234 Once per box that runs VMs. The server's box counts.
155 235
@@ -167,7 +247,7 @@ sudo systemctl enable --now eitri-agent
167 The host goes **online** in the console. Logs: 247 The host goes **online** in the console. Logs:
168 `journalctl -u eitri-agent -f`. 248 `journalctl -u eitri-agent -f`.
169 249
170 ## Boot a VM 250 ### Boot a VM
171 251
172 VMs trust your SSH CA from birth, so register one first. eitri gets the 252 VMs trust your SSH CA from birth, so register one first. eitri gets the
173 public key, never the private one. On your laptop: 253 public key, never the private one. On your laptop:
@@ -190,7 +270,7 @@ email on first sign-in.
190 default image. Status reads `creating` while the image downloads and the 270 default image. Status reads `creating` while the image downloads and the
191 guest boots, then `ready`. Power reads `running`, an IP appears, you're on. 271 guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
192 272
193 ## SSH in 273 ### SSH in
194 274
195 ```sh 275 ```sh
196 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain 276 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain
@@ -205,7 +285,7 @@ your CA, pins eitri's host CA, and jumps the gate to
205 `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md) 285 `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md)
206 shows it done by hand. 286 shows it done by hand.
207 287
208 ## More 288 ### More
209 289
210 - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model 290 - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model
211 - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor 291 - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor
docs/releases.md
Old New
@@ -7,9 +7,10 @@ running VM.
7 7
8 ## v0.0.1 8 ## v0.0.1
9 9
10 The first release. eitri is a small self-hosted cloud: one control-plane 10 The first release. eitri is a small cloud backed by your own hardware: one
11 server, an agent on every Linux/KVM box you enroll, cloud-hypervisor microVMs, 11 control-plane server — hosted at eitri.sh or run yourself — an agent on every
12 a web console, and SSH into guests through an identity-checking jump gate. 12 Linux/KVM box you enroll, cloud-hypervisor microVMs, a web console, and SSH
13 into guests through an identity-checking jump gate.
13 14
14 **Fleet.** Enroll any Linux/KVM host with a one-shot join command from the 15 **Fleet.** Enroll any Linux/KVM host with a one-shot join command from the
15 console. The agent reconciles each VM in its own worker, admits VMs against 16 console. The agent reconciles each VM in its own worker, admits VMs against
@@ -34,6 +35,12 @@ private key. `eitri ssh` self-signs a short-lived certificate, pins eitri's
34 host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable 35 host CA, and jumps the gate to `<tenant>.<vm>`; certificates are revocable
35 per serial. See [ssh-access.md](ssh-access.md). 36 per serial. See [ssh-access.md](ssh-access.md).
36 37
38 **Hosted.** Don't want to run the control plane? Sign in at
39 [console.eitri.sh](https://console.eitri.sh) with Google and join your own
40 machines — the same server the tarball ships, run for you. Your hardware
41 still serves every VM; the hosted plane only coordinates it. Self-hosting
42 stays a first-class path.
43
37 **Running it.** `eitri-server` and `eitri-oidc` ship with hardened systemd 44 **Running it.** `eitri-server` and `eitri-oidc` ship with hardened systemd
38 units and run as dedicated non-root users; the agent's unit documents exactly 45 units and run as dedicated non-root users; the agent's unit documents exactly
39 why it runs as root. Server and issuer state live under `/var/lib`, config 46 why it runs as root. Server and issuer state live under `/var/lib`, config
scripts/deploy.env.example
Old New
@@ -83,3 +83,9 @@ FIRMWARE="/usr/share/eitri/CLOUDHV.fd"
83 # than staged by the site image. `make release` reads FIRMWARE_SRC — a local 83 # than staged by the site image. `make release` reads FIRMWARE_SRC — a local
84 # CLOUDHV.fd to mirror in — defaulting to $HOME/.cache/eitri/CLOUDHV.fd if 84 # CLOUDHV.fd to mirror in — defaulting to $HOME/.cache/eitri/CLOUDHV.fd if
85 # present; see scripts/release.sh. 85 # present; see scripts/release.sh.
86
87 # ── eitri-server production image (make server-image) ─────────────────────────
88 # Registry/repo for the hosted control-plane image; tagged with the release
89 # version. Required for `make server-image` (everything else here ignores it).
90 # The image is arm64 only — the pod is pinned to the cluster's arm64 node.
91 # SERVER_IMAGE="registry.example.com/eitri-server"
scripts/server-image.sh
Old New
@@ -0,0 +1,31 @@
1 #!/usr/bin/env bash
2 # Build and push the eitri-server production image (arm64 — the pod is
3 # pinned to the cluster's arm64 public-IP node; amd64 installs are served by
4 # the release tarballs, not this image).
5 #
6 # Reads deploy.env (same file scripts/deploy.sh uses):
7 # SERVER_IMAGE registry/repo to push, e.g. registry.example/eitri-server (required)
8 #
9 # Expects `make web` to have run (internal/server/web/dist — the SPA is
10 # //go:embed'ed into the binary); the Makefile's server-image target orders it.
11 set -euo pipefail
12 cd "$(dirname "$0")/.."
13
14 ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}"
15 # shellcheck disable=SC1090
16 [ -f "$ENV_FILE" ] && . "$ENV_FILE"
17 : "${SERVER_IMAGE:?server-image: set SERVER_IMAGE in $ENV_FILE}"
18
19 VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
20 [ -f internal/server/web/dist/index.html ] || { echo "server-image: web dist missing — run make web" >&2; exit 1; }
21
22 LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION"
23 BUILD_DIR="$(mktemp -d)"
24 trap 'rm -rf "$BUILD_DIR"' EXIT
25 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \
26 -o "$BUILD_DIR/eitri-server" ./cmd/eitri-server
27 cp deploy/server/Dockerfile "$BUILD_DIR/"
28
29 docker build --platform linux/arm64 -t "$SERVER_IMAGE:$VERSION" "$BUILD_DIR"
30 docker push "$SERVER_IMAGE:$VERSION"
31 echo "server-image: pushed $SERVER_IMAGE:$VERSION — roll it out with kubectl -n eitri set image deployment/eitri-server eitri-server=$SERVER_IMAGE:$VERSION"
site/index.md
Old New
@@ -6,8 +6,7 @@
6 6
7 eitri connects machines you already own into a private cloud. The 7 eitri connects machines you already own into a private cloud. The
8 workstation you replaced, the mini PC in a drawer — point eitri at them 8 workstation you replaced, the mini PC in a drawer — point eitri at them
9 and they serve VMs the way a cloud does, without the bill. No account, no 9 and they serve VMs the way a cloud does, without the bill.
10 landlord.
11 10
12 Boot a throwaway sandbox for a risky experiment. Keep a dev machine that 11 Boot a throwaway sandbox for a risky experiment. Keep a dev machine that
13 survives host reboots. Give an AI agent a VM where it can run wild. Delete 12 survives host reboots. Give an AI agent a VM where it can run wild. Delete
site/template.html
Old New
@@ -12,6 +12,7 @@
12 <a href="/"{{if eq .Section "home"}} class="active"{{end}}>home</a> 12 <a href="/"{{if eq .Section "home"}} class="active"{{end}}>home</a>
13 <a href="/docs/"{{if eq .Section "docs"}} class="active"{{end}}>docs</a> 13 <a href="/docs/"{{if eq .Section "docs"}} class="active"{{end}}>docs</a>
14 <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a> 14 <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a>
15 <a href="https://console.eitri.sh"{{if eq .Section "console"}} class="active"{{end}}>sign in</a>
15 </nav> 16 </nav>
16 17
17 {{.Content}} 18 {{.Content}}