4c10e425
feat(site): eitri.sh — site, docs, releases, and the publish image
a73x 2026-07-26 14:21
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -28,3 +28,6 @@ deploy.env | |||
| 28 | # Local dev-process working docs — kept OUT of the repo by convention. | 28 | # Local dev-process working docs — kept OUT of the repo by convention. |
| 29 | /docs/superpowers/ | 29 | /docs/superpowers/ |
| 30 | /scratchpad/ | 30 | /scratchpad/ |
| 31 | |||
| 32 | /dist/ | ||
| 33 | site/dist/ | ||
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -11,8 +11,9 @@ DEADCODE_VERSION := v0.48.0 | |||
| 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | 11 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret |
| 12 | 12 | ||
| 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 ci deadcode \ | 14 | lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check \ |
| 15 | deploy hooks | 15 | site site-check ci deadcode \ |
| 16 | deploy release hooks site-image | ||
| 16 | 17 | ||
| 17 | # 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 |
| 18 | # 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 |
| @@ -38,6 +39,7 @@ build: web | |||
| 38 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent | 39 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent |
| 39 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp | 40 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-mcp ./cmd/eitri-mcp |
| 40 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke | 41 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke |
| 42 | go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site | ||
| 41 | 43 | ||
| 42 | test: | 44 | test: |
| 43 | go test -race ./... | 45 | go test -race ./... |
| @@ -74,6 +76,12 @@ api-check: | |||
| 74 | deploy: | 76 | deploy: |
| 75 | ./scripts/deploy.sh | 77 | ./scripts/deploy.sh |
| 76 | 78 | ||
| 79 | # Cross-compiled release tarballs + checksums + agent-upgrade manifest into | ||
| 80 | # dist/<version>/ (see scripts/release.sh). Refuses a dirty tree; releases | ||
| 81 | # that agents can upgrade to must be built from a clean TAGGED tree. | ||
| 82 | release: web | ||
| 83 | ./scripts/release.sh | ||
| 84 | |||
| 77 | # --- quality gates ----------------------------------------------------------- | 85 | # --- quality gates ----------------------------------------------------------- |
| 78 | 86 | ||
| 79 | # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests | 87 | # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests |
| @@ -125,6 +133,32 @@ shape-check: | |||
| 125 | git diff --exit-code docs/shape.json docs/shape.html || \ | 133 | git diff --exit-code docs/shape.json docs/shape.html || \ |
| 126 | { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; } | 134 | { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; } |
| 127 | 135 | ||
| 136 | # Generate the eitri.sh static site into site/dist. Picks up the newest | ||
| 137 | # dist/<version> (from `make release`) for the downloads page when one | ||
| 138 | # exists; renders a docs-only preview otherwise. | ||
| 139 | SITE_DIST = $(shell find dist -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1) | ||
| 140 | site: | ||
| 141 | rm -rf site/dist | ||
| 142 | go run ./cmd/eitri-site -docs docs -site site -out site/dist \ | ||
| 143 | $(if $(SITE_DIST),-dist $(SITE_DIST)) | ||
| 144 | |||
| 145 | # Merge gate: the site must build from the real docs tree — a broken | ||
| 146 | # inter-doc link fails here, keeping repo docs and eitri.sh in lockstep. | ||
| 147 | site-check: | ||
| 148 | @tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \ | ||
| 149 | go run ./cmd/eitri-site -docs docs -site site -out $$tmp && \ | ||
| 150 | for f in index.html style.css openapi.json docs/index.html docs/quickstart/index.html docs/upgrade/index.html dl/index.html; do \ | ||
| 151 | test -f $$tmp/$$f || { echo "site-check: missing $$f"; exit 1; }; \ | ||
| 152 | done && echo "site-check: ok" | ||
| 153 | |||
| 154 | # Build and push the eitri.sh site image (nginx + site + /dl artifacts). | ||
| 155 | # Needs SITE_IMAGE (and optionally SITE_FIRMWARE_SRC) in deploy.env. | ||
| 156 | # Rollout on k8s is manual and stays outside the repo. | ||
| 157 | site-image: | ||
| 158 | $(MAKE) release | ||
| 159 | $(MAKE) site SITE_DIST=dist/$(VERSION) | ||
| 160 | ./scripts/site-image.sh | ||
| 161 | |||
| 128 | # Whole-program dead-code gate: fails on any function unreachable from a real | 162 | # Whole-program dead-code gate: fails on any function unreachable from a real |
| 129 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what | 163 | # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what |
| 130 | # catches production code kept alive only by its own tests; the fix is to remove | 164 | # catches production code kept alive only by its own tests; the fix is to remove |
| @@ -139,7 +173,7 @@ deadcode: | |||
| 139 | # The merge gate. Mirrors the required checks in CI. `test` is the authoritative | 173 | # The merge gate. Mirrors the required checks in CI. `test` is the authoritative |
| 140 | # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` | 174 | # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch` |
| 141 | # re-runs the fitness tests with -count=1 (the race run may serve them cached). | 175 | # re-runs the fitness tests with -count=1 (the race run may serve them cached). |
| 142 | ci: vet build-go arch lint test cover tidy-check proto-check api-check shape-check deadcode | 176 | ci: vet build-go arch lint test cover tidy-check proto-check api-check shape-check deadcode site-check |
| 143 | 177 | ||
| 144 | # Compile every Go package (no Node/web build needed — the embed dir ships a | 178 | # Compile every Go package (no Node/web build needed — the embed dir ships a |
| 145 | # placeholder, so the server builds and serves a "UI not built" notice). | 179 | # placeholder, so the server builds and serves a "UI not built" notice). |
cmd/eitri-site/main.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,70 @@ | |||
| 1 | // Command eitri-site generates the eitri.sh static site (default) or the | ||
| 2 | // release manifest (the "manifest" subcommand). See internal/site. | ||
| 3 | package main | ||
| 4 | |||
| 5 | import ( | ||
| 6 | "encoding/json" | ||
| 7 | "flag" | ||
| 8 | "fmt" | ||
| 9 | "os" | ||
| 10 | |||
| 11 | "github.com/a73x/eitri/internal/site" | ||
| 12 | "github.com/a73x/eitri/internal/version" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func main() { | ||
| 16 | if len(os.Args) > 1 && os.Args[1] == "--version" { | ||
| 17 | fmt.Println(version.Version) | ||
| 18 | return | ||
| 19 | } | ||
| 20 | if len(os.Args) > 1 && os.Args[1] == "manifest" { | ||
| 21 | if err := runManifest(os.Args[2:]); err != nil { | ||
| 22 | fmt.Fprintln(os.Stderr, "eitri-site manifest:", err) | ||
| 23 | os.Exit(1) | ||
| 24 | } | ||
| 25 | return | ||
| 26 | } | ||
| 27 | if err := runBuild(os.Args[1:]); err != nil { | ||
| 28 | fmt.Fprintln(os.Stderr, "eitri-site:", err) | ||
| 29 | os.Exit(1) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | func runBuild(args []string) error { | ||
| 34 | fs := flag.NewFlagSet("eitri-site", flag.ExitOnError) | ||
| 35 | docs := fs.String("docs", "docs", "docs directory (markdown sources)") | ||
| 36 | siteDir := fs.String("site", "site", "site directory (index.md, template.html, style.css)") | ||
| 37 | dist := fs.String("dist", "", "optional dist/<version> dir with release artifacts") | ||
| 38 | out := fs.String("out", "site/dist", "output webroot") | ||
| 39 | if err := fs.Parse(args); err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | return site.Build(site.Config{DocsDir: *docs, SiteDir: *siteDir, DistDir: *dist, OutDir: *out}) | ||
| 43 | } | ||
| 44 | |||
| 45 | func runManifest(args []string) error { | ||
| 46 | fs := flag.NewFlagSet("eitri-site manifest", flag.ExitOnError) | ||
| 47 | ver := fs.String("version", "", "release version (vX.Y.Z)") | ||
| 48 | dist := fs.String("dist", "", "dist/<version> dir holding bare agent binaries") | ||
| 49 | base := fs.String("base", "", "base URL artifacts are served from") | ||
| 50 | out := fs.String("out", "", "output path (default <dist>/manifest.json)") | ||
| 51 | if err := fs.Parse(args); err != nil { | ||
| 52 | return err | ||
| 53 | } | ||
| 54 | if *ver == "" || *dist == "" || *base == "" { | ||
| 55 | return fmt.Errorf("-version, -dist, and -base are required") | ||
| 56 | } | ||
| 57 | m, err := site.BuildManifest(*ver, *dist, *base) | ||
| 58 | if err != nil { | ||
| 59 | return err | ||
| 60 | } | ||
| 61 | raw, err := json.MarshalIndent(m, "", " ") | ||
| 62 | if err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | path := *out | ||
| 66 | if path == "" { | ||
| 67 | path = *dist + "/manifest.json" | ||
| 68 | } | ||
| 69 | return os.WriteFile(path, append(raw, '\n'), 0o644) | ||
| 70 | } | ||
docs/README.md
| Old | New | ||
|---|---|---|---|
| @@ -17,8 +17,9 @@ By what you're trying to do: | |||
| 17 | and why expiry is never an emergency | 17 | and why expiry is never an emergency |
| 18 | - [credential-revocation.md](credential-revocation.md) — leaked host | 18 | - [credential-revocation.md](credential-revocation.md) — leaked host |
| 19 | credentials, leaked SSH certs, and the disaster levers | 19 | credentials, leaked SSH certs, and the disaster levers |
| 20 | - Quickstart and upgrade guide are planned for v0.0.1 — see | 20 | - [upgrade.md](upgrade.md) — upgrading agents from the console, the server, |
| 21 | [ROADMAP.md](../ROADMAP.md) | 21 | and cloud-hypervisor |
| 22 | - Quickstart is planned for v0.0.1 — see [ROADMAP.md](../ROADMAP.md) | ||
| 22 | 23 | ||
| 23 | **Use a fleet** | 24 | **Use a fleet** |
| 24 | 25 | ||
docs/credential-revocation.md
| Old | New | ||
|---|---|---|---|
| @@ -44,7 +44,7 @@ mechanism. | |||
| 44 | 44 | ||
| 45 | Guest SSH access uses short-lived certificates self-signed with a tenant's own | 45 | Guest SSH access uses short-lived certificates self-signed with a tenant's own |
| 46 | user CA (see [ssh-access.md](ssh-access.md)) — eitri holds no user signing key. | 46 | user CA (see [ssh-access.md](ssh-access.md)) — eitri holds no user signing key. |
| 47 | The short validity you sign with (`hack/eitri-ssh` uses 30 minutes) is the | 47 | The short validity you sign with (`eitri-ssh` uses 30 minutes) is the |
| 48 | first line of defense: a leaked cert expires on its own. | 48 | first line of defense: a leaked cert expires on its own. |
| 49 | 49 | ||
| 50 | Before it does, a specific cert can be revoked at the gate by serial | 50 | Before it does, a specific cert can be revoked at the gate by serial |
docs/faq.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,13 @@ | |||
| 1 | # FAQ | ||
| 2 | |||
| 3 | ## How do VMs get network access? | ||
| 4 | |||
| 5 | Outbound works out of the box: guests are NAT'd through their host and reach | ||
| 6 | the internet like any process on it. Inbound is deliberately minimal: guests | ||
| 7 | live on private per-host bridges, and the only built-in way in is the SSH | ||
| 8 | [jump gate](ssh-access.md). | ||
| 9 | |||
| 10 | To serve traffic from a VM, or to reach one directly from another network, | ||
| 11 | install [Tailscale](https://tailscale.com) (or WireGuard) inside it — it is | ||
| 12 | a normal Linux machine. A public gateway is on the | ||
| 13 | [roadmap](../ROADMAP.md). | ||
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,140 @@ | |||
| 1 | # Quickstart | ||
| 2 | |||
| 3 | *From nothing to a VM you can SSH into* | ||
| 4 | |||
| 5 | 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. | ||
| 7 | |||
| 8 | ## What you need | ||
| 9 | |||
| 10 | Every VM host needs KVM (`ls -l /dev/kvm`), `qemu-img` (Debian/Ubuntu: | ||
| 11 | `qemu-utils`, Fedora: `qemu-img`), cloud-hypervisor, and the guest firmware: | ||
| 12 | |||
| 13 | ```sh | ||
| 14 | sudo install -m 0755 cloud-hypervisor /usr/local/bin/cloud-hypervisor | ||
| 15 | sudo install -D -m 0644 <(curl -fsSL https://eitri.sh/dl/firmware/CLOUDHV.fd) \ | ||
| 16 | /usr/share/eitri/CLOUDHV.fd | ||
| 17 | ``` | ||
| 18 | |||
| 19 | cloud-hypervisor is a static binary from the | ||
| 20 | [upstream releases](https://github.com/cloud-hypervisor/cloud-hypervisor/releases). | ||
| 21 | |||
| 22 | Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle | ||
| 23 | (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and | ||
| 24 | the agent's systemd unit. The client bundle (`eitri-ssh_<version>.tar.gz`) | ||
| 25 | has `eitri-ssh` and `eitri-ca` for your laptop. arm64 boxes take the arm64 | ||
| 26 | bundle. | ||
| 27 | |||
| 28 | ## The server | ||
| 29 | |||
| 30 | ```sh | ||
| 31 | tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64 | ||
| 32 | sudo install -m 0755 eitri-server /usr/local/bin/eitri-server | ||
| 33 | sudo mkdir -p /etc/eitri /var/lib/eitri | ||
| 34 | ``` | ||
| 35 | |||
| 36 | Set `SERVER_ADDR`, paste the rest: | ||
| 37 | |||
| 38 | ```sh | ||
| 39 | SERVER_ADDR=192.0.2.10 | ||
| 40 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current | ||
| 41 | IMAGE_FILE=resolute-server-cloudimg-amd64.img | ||
| 42 | ADMIN_TOKEN=$(openssl rand -hex 32) | ||
| 43 | HOST_SECRET=$(openssl rand -hex 32) | ||
| 44 | IMAGE_SHA256=$(curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="$IMAGE_FILE" '$2 == "*" f {print $1}') | ||
| 45 | |||
| 46 | sudo tee /etc/eitri/server.json >/dev/null <<EOF | ||
| 47 | { | ||
| 48 | "http_listen": ":8080", | ||
| 49 | "quic_listen": ":8443", | ||
| 50 | "advertise_http": "http://$SERVER_ADDR:8080", | ||
| 51 | "advertise_quic": "$SERVER_ADDR:8443", | ||
| 52 | "db_path": "/var/lib/eitri/eitri.db", | ||
| 53 | "cidr_pool": "10.100.0.0/16", | ||
| 54 | "admin_token": "$ADMIN_TOKEN", | ||
| 55 | "host_secret": "$HOST_SECRET", | ||
| 56 | "default_image_url": "$IMAGE_DIR/$IMAGE_FILE", | ||
| 57 | "default_image_sha256": "$IMAGE_SHA256", | ||
| 58 | "ssh_listen": ":2222", | ||
| 59 | "ssh_gate_domain": "$SERVER_ADDR", | ||
| 60 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", | ||
| 61 | "ssh_host_key": "/var/lib/eitri/ssh_host_key" | ||
| 62 | } | ||
| 63 | EOF | ||
| 64 | |||
| 65 | echo "console login token: $ADMIN_TOKEN" # keep this | ||
| 66 | ``` | ||
| 67 | |||
| 68 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any | ||
| 69 | cloud-init disk image works as the default image; the Ubuntu one boots out of | ||
| 70 | the box. | ||
| 71 | |||
| 72 | Run it: | ||
| 73 | |||
| 74 | ```sh | ||
| 75 | sudo eitri-server --config /etc/eitri/server.json | ||
| 76 | ``` | ||
| 77 | |||
| 78 | It runs in the foreground. nohup, tmux, or write a unit. It speaks plain | ||
| 79 | HTTP, so keep it on your LAN or put TLS in front. Open `8080/tcp` (console, | ||
| 80 | enroll), `8443/udp` (sync), `2222/tcp` (SSH gate). | ||
| 81 | |||
| 82 | Log in at `http://192.0.2.10:8080` with the token echoed above. | ||
| 83 | |||
| 84 | ## Join a host | ||
| 85 | |||
| 86 | Once per box that runs VMs. The server's box counts. | ||
| 87 | |||
| 88 | **+ Add host** in the console prints a one-shot join command. On the box, | ||
| 89 | from the unpacked host bundle: | ||
| 90 | |||
| 91 | ```sh | ||
| 92 | sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent | ||
| 93 | sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service | ||
| 94 | sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console> | ||
| 95 | sudo systemctl daemon-reload | ||
| 96 | sudo systemctl enable --now eitri-agent | ||
| 97 | ``` | ||
| 98 | |||
| 99 | The host goes **online** in the console. Logs: | ||
| 100 | `journalctl -u eitri-agent -f`. | ||
| 101 | |||
| 102 | ## Boot a VM | ||
| 103 | |||
| 104 | VMs trust your SSH CA from birth, so register one first. eitri gets the | ||
| 105 | public key, never the private one. On your laptop: | ||
| 106 | |||
| 107 | ```sh | ||
| 108 | tar xzf eitri-ssh_*.tar.gz | ||
| 109 | sudo install -m 0755 eitri-ssh_*/eitri-ssh eitri-ssh_*/eitri-ca /usr/local/bin/ | ||
| 110 | |||
| 111 | export EITRI_URL=http://192.0.2.10:8080 | ||
| 112 | export EITRI_TOKEN=<console-login-token> | ||
| 113 | |||
| 114 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" | ||
| 115 | eitri-ca upload default ~/.ssh/eitri_user_ca.pub | ||
| 116 | ``` | ||
| 117 | |||
| 118 | **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the | ||
| 119 | default image. Status reads `creating` while the image downloads and the | ||
| 120 | guest boots, then `ready`. Power reads `running`, an IP appears, you're on. | ||
| 121 | |||
| 122 | ## SSH in | ||
| 123 | |||
| 124 | ```sh | ||
| 125 | export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain | ||
| 126 | |||
| 127 | eitri-ssh <vm-name> | ||
| 128 | eitri-ssh <vm-name> uptime | ||
| 129 | ``` | ||
| 130 | |||
| 131 | `eitri-ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with | ||
| 132 | your CA, pins eitri's host CA, and jumps the gate to | ||
| 133 | `ubuntu@default.<vm-name>`. No token. [ssh-access.md](ssh-access.md) shows it | ||
| 134 | done by hand. | ||
| 135 | |||
| 136 | ## More | ||
| 137 | |||
| 138 | - [ssh-access.md](ssh-access.md): the jump gate and the BYO-CA model | ||
| 139 | - [upgrade.md](upgrade.md): upgrading agents, the server, cloud-hypervisor | ||
| 140 | - [credential-revocation.md](credential-revocation.md): when something leaks | ||
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -121,6 +121,15 @@ | |||
| 121 | ] | 121 | ] |
| 122 | }, | 122 | }, |
| 123 | { | 123 | { |
| 124 | "importPath": "cmd/eitri-site", | ||
| 125 | "plane": "binaries", | ||
| 126 | "synopsis": "Command eitri-site generates the eitri.sh static site (default) or the release manifest (the \"manifest\" subcommand).", | ||
| 127 | "imports": [ | ||
| 128 | "internal/site", | ||
| 129 | "internal/version" | ||
| 130 | ] | ||
| 131 | }, | ||
| 132 | { | ||
| 124 | "importPath": "cmd/eitri-smoke", | 133 | "importPath": "cmd/eitri-smoke", |
| 125 | "plane": "binaries", | 134 | "plane": "binaries", |
| 126 | "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", | 135 | "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", |
| @@ -424,6 +433,14 @@ | |||
| 424 | "imports": [] | 433 | "imports": [] |
| 425 | }, | 434 | }, |
| 426 | { | 435 | { |
| 436 | "importPath": "internal/site", | ||
| 437 | "plane": "tooling", | ||
| 438 | "synopsis": "Package site generates the eitri.sh static site: docs/*.md and a markdown landing page rendered through one HTML template, plus the downloads page and the agent-upgrade release manifest.", | ||
| 439 | "imports": [ | ||
| 440 | "internal/server/release" | ||
| 441 | ] | ||
| 442 | }, | ||
| 443 | { | ||
| 427 | "importPath": "internal/transport", | 444 | "importPath": "internal/transport", |
| 428 | "plane": "wire", | 445 | "plane": "wire", |
| 429 | "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", | 446 | "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -70,6 +70,15 @@ | |||
| 70 | ] | 70 | ] |
| 71 | }, | 71 | }, |
| 72 | { | 72 | { |
| 73 | "importPath": "cmd/eitri-site", | ||
| 74 | "plane": "binaries", | ||
| 75 | "synopsis": "Command eitri-site generates the eitri.sh static site (default) or the release manifest (the \"manifest\" subcommand).", | ||
| 76 | "imports": [ | ||
| 77 | "internal/site", | ||
| 78 | "internal/version" | ||
| 79 | ] | ||
| 80 | }, | ||
| 81 | { | ||
| 73 | "importPath": "cmd/eitri-smoke", | 82 | "importPath": "cmd/eitri-smoke", |
| 74 | "plane": "binaries", | 83 | "plane": "binaries", |
| 75 | "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", | 84 | "synopsis": "Command eitri-smoke drives the live eitri fleet through create -\u003e boot-proof -\u003e reap of one throwaway VM, exiting non-zero on any failure.", |
| @@ -373,6 +382,14 @@ | |||
| 373 | "imports": [] | 382 | "imports": [] |
| 374 | }, | 383 | }, |
| 375 | { | 384 | { |
| 385 | "importPath": "internal/site", | ||
| 386 | "plane": "tooling", | ||
| 387 | "synopsis": "Package site generates the eitri.sh static site: docs/*.md and a markdown landing page rendered through one HTML template, plus the downloads page and the agent-upgrade release manifest.", | ||
| 388 | "imports": [ | ||
| 389 | "internal/server/release" | ||
| 390 | ] | ||
| 391 | }, | ||
| 392 | { | ||
| 376 | "importPath": "internal/transport", | 393 | "importPath": "internal/transport", |
| 377 | "plane": "wire", | 394 | "plane": "wire", |
| 378 | "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", | 395 | "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.", |
docs/ssh-access.md
| Old | New | ||
|---|---|---|---|
| @@ -15,8 +15,8 @@ host-key-changed warnings when VM names or IPs are recycled. | |||
| 15 | 15 | ||
| 16 | Two CAs, two directions: **your tenant's user CA** (private key on your machine) | 16 | Two CAs, two directions: **your tenant's user CA** (private key on your machine) |
| 17 | signs what you present; **eitri's host CA** (private key on the server) signs | 17 | signs what you present; **eitri's host CA** (private key on the server) signs |
| 18 | what the gate and VMs present. See [decisions.md](decisions.md) for why eitri | 18 | what the gate and VMs present. eitri deliberately holds no user signing key — |
| 19 | holds no user signing key. | 19 | a server compromise cannot mint user credentials. |
| 20 | 20 | ||
| 21 | ## Bring your own CA (once per tenant) | 21 | ## Bring your own CA (once per tenant) |
| 22 | 22 | ||
| @@ -28,7 +28,7 @@ ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" | |||
| 28 | 28 | ||
| 29 | export EITRI_URL=https://eitri.example.com | 29 | export EITRI_URL=https://eitri.example.com |
| 30 | export EITRI_TOKEN=<admin-bearer-token> | 30 | export EITRI_TOKEN=<admin-bearer-token> |
| 31 | hack/eitri-ca upload default ~/.ssh/eitri_user_ca.pub | 31 | eitri-ca upload default ~/.ssh/eitri_user_ca.pub |
| 32 | ``` | 32 | ``` |
| 33 | 33 | ||
| 34 | The CA's private key never leaves your machine; the server stores only the | 34 | The CA's private key never leaves your machine; the server stores only the |
| @@ -42,12 +42,12 @@ tenant the signing CA was uploaded to. | |||
| 42 | export EITRI_URL=https://eitri.example.com | 42 | export EITRI_URL=https://eitri.example.com |
| 43 | export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address | 43 | export EITRI_GATE=eitri.example.com:2222 # the gate's ssh_listen address |
| 44 | 44 | ||
| 45 | hack/eitri-ssh <vm-name> # opens a shell on the VM | 45 | eitri-ssh <vm-name> # opens a shell on the VM |
| 46 | hack/eitri-ssh <vm-name> uptime # runs a command and exits | 46 | eitri-ssh <vm-name> uptime # runs a command and exits |
| 47 | ``` | 47 | ``` |
| 48 | 48 | ||
| 49 | You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect | 49 | You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect |
| 50 | name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `hack/eitri-ssh` | 50 | name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `eitri-ssh` |
| 51 | builds it from `EITRI_TENANT`. | 51 | builds it from `EITRI_TENANT`. |
| 52 | 52 | ||
| 53 | Environment variables: | 53 | Environment variables: |
| @@ -101,7 +101,7 @@ The helper is a thin wrapper over three steps you can run by hand: | |||
| 101 | ## Certs are short-lived | 101 | ## Certs are short-lived |
| 102 | 102 | ||
| 103 | Self-signed certs should carry a short validity (`-V +30m` above). When one | 103 | Self-signed certs should carry a short validity (`-V +30m` above). When one |
| 104 | expires, ssh is simply rejected — re-run `hack/eitri-ssh` (or the signing step) | 104 | expires, ssh is simply rejected — re-run `eitri-ssh` (or the signing step) |
| 105 | to refresh. A specific cert can also be revoked at the gate by serial before it | 105 | to refresh. A specific cert can also be revoked at the gate by serial before it |
| 106 | expires; see [credential-revocation.md](credential-revocation.md). | 106 | expires; see [credential-revocation.md](credential-revocation.md). |
| 107 | 107 | ||
| @@ -139,7 +139,7 @@ The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | |||
| 139 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the | 139 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the |
| 140 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by | 140 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by |
| 141 | CA, recycling a VM name or IP never produces a host-key-changed warning — the | 141 | CA, recycling a VM name or IP never produces a host-key-changed warning — the |
| 142 | new VM simply presents a fresh CA-signed cert for that name. `hack/eitri-ssh` | 142 | new VM simply presents a fresh CA-signed cert for that name. `eitri-ssh` |
| 143 | does all of this for you. | 143 | does all of this for you. |
| 144 | 144 | ||
| 145 | ## Related | 145 | ## Related |
docs/upgrade.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,62 @@ | |||
| 1 | # Upgrading | ||
| 2 | |||
| 3 | Three things version independently: the per-host agent, the server, and | ||
| 4 | cloud-hypervisor. None of them touch running VMs. | ||
| 5 | |||
| 6 | ## Agents, from the console | ||
| 7 | |||
| 8 | The fleet overview shows each host's agent version. When the server knows a | ||
| 9 | newer release (it polls `https://eitri.sh/dl/latest/manifest.json` daily), an | ||
| 10 | `↑` button appears next to hosts that are behind. Clicking it tells that one | ||
| 11 | agent to upgrade itself: | ||
| 12 | |||
| 13 | 1. The agent downloads the new binary from eitri.sh and verifies its sha256 | ||
| 14 | against the release manifest. | ||
| 15 | 2. It swaps the binary in place — the old one is kept next to it as | ||
| 16 | `eitri-agent.prev` — and re-execs. The process keeps its PID; running VMs | ||
| 17 | are untouched and stay under the agent's care throughout. | ||
| 18 | 3. The host reports its new version on the next sync, and the button | ||
| 19 | disappears. | ||
| 20 | |||
| 21 | If a download fails, nothing is swapped; click again to retry. To roll back by | ||
| 22 | hand, stop the agent, move `eitri-agent.prev` back over the binary, and start | ||
| 23 | it again. | ||
| 24 | |||
| 25 | **Requirements.** The button lights up only when the running agent reports a | ||
| 26 | release version (`vX.Y.Z`) — agents built from an untagged or dirty tree | ||
| 27 | report a git hash instead and are never offered upgrades. The server needs | ||
| 28 | `release_manifest_url` reachable; set it to `""` in the server config to | ||
| 29 | disable upgrade checks entirely. | ||
| 30 | |||
| 31 | ## The server | ||
| 32 | |||
| 33 | The console banner links here when the published release differs from the | ||
| 34 | running server's version. Server upgrades are manual and downtime is fine — agents keep | ||
| 35 | reconciling and VMs keep running while it's away: | ||
| 36 | |||
| 37 | 1. Stop `eitri-server`. | ||
| 38 | 2. Replace the binary with the new release. | ||
| 39 | 3. Start it. The schema is applied idempotently on boot (existing tables are | ||
| 40 | left as-is), and agents reconnect on their own. | ||
| 41 | |||
| 42 | A newer server with older agents is safe: new fields in the sync protocol are | ||
| 43 | simply ignored by agents that predate them. | ||
| 44 | |||
| 45 | ## cloud-hypervisor | ||
| 46 | |||
| 47 | Swap `/usr/local/bin/cloud-hypervisor` on the host. New and restarted VMs use | ||
| 48 | the new binary; running VMs keep their old process until they stop. There is | ||
| 49 | no live handover for running guests. | ||
| 50 | |||
| 51 | ## Operations | ||
| 52 | |||
| 53 | The agent runs under systemd (`eitri-agent.service`). The unit sets | ||
| 54 | `KillMode=process` — that line is load-bearing: the default would kill every | ||
| 55 | cloud-hypervisor guest in the unit's cgroup whenever the agent stops. Logs: | ||
| 56 | `journalctl -u eitri-agent`. | ||
| 57 | |||
| 58 | ## Related | ||
| 59 | |||
| 60 | - [cert-rotation.md](cert-rotation.md) — rotating the server's QUIC identity | ||
| 61 | - [credential-revocation.md](credential-revocation.md) — the disaster levers | ||
| 62 | when credentials leak | ||
go.mod
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ require ( | |||
| 10 | github.com/pkg/sftp v1.13.11 | 10 | github.com/pkg/sftp v1.13.11 |
| 11 | github.com/quic-go/quic-go v0.48.2 | 11 | github.com/quic-go/quic-go v0.48.2 |
| 12 | github.com/stretchr/testify v1.11.1 | 12 | github.com/stretchr/testify v1.11.1 |
| 13 | github.com/yuin/goldmark v1.8.4 | ||
| 13 | golang.org/x/crypto v0.54.0 | 14 | golang.org/x/crypto v0.54.0 |
| 14 | golang.org/x/sync v0.20.0 | 15 | golang.org/x/sync v0.20.0 |
| 15 | google.golang.org/protobuf v1.36.11 | 16 | google.golang.org/protobuf v1.36.11 |
go.sum
| Old | New | ||
|---|---|---|---|
| @@ -85,6 +85,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= | |||
| 85 | github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= | 85 | github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= |
| 86 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= | 86 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= |
| 87 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= | 87 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= |
| 88 | github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= | ||
| 89 | github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= | ||
| 88 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= | 90 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= |
| 89 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= | 91 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= |
| 90 | golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= | 92 | golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= |
internal/shape/classify.go
| Old | New | ||
|---|---|---|---|
| @@ -43,6 +43,7 @@ func classify(rel string) Plane { | |||
| 43 | strings.HasPrefix(rel, "internal/mcpserver"), | 43 | strings.HasPrefix(rel, "internal/mcpserver"), |
| 44 | strings.HasPrefix(rel, "internal/covsnap"), | 44 | strings.HasPrefix(rel, "internal/covsnap"), |
| 45 | strings.HasPrefix(rel, "internal/gateclient"), | 45 | strings.HasPrefix(rel, "internal/gateclient"), |
| 46 | strings.HasPrefix(rel, "internal/site"), | ||
| 46 | strings.HasPrefix(rel, "internal/shape"): | 47 | strings.HasPrefix(rel, "internal/shape"): |
| 47 | return PlaneTooling | 48 | return PlaneTooling |
| 48 | default: | 49 | default: |
internal/site/dl.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,88 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "sort" | ||
| 8 | "strings" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // downloadsMarkdown renders the /dl/ page body from a dist/<version> dir: | ||
| 12 | // every artifact linked at its stable /dl/<version>/ URL with size and | ||
| 13 | // sha256, plus the verification snippet. An empty distDir renders a | ||
| 14 | // docs-only preview note instead. | ||
| 15 | func downloadsMarkdown(distDir string) (string, error) { | ||
| 16 | if distDir == "" { | ||
| 17 | return "# downloads\n\nNo release is staged in this build. Release artifacts live under\n`/dl/<version>/`, with `/dl/latest/` pointing at the newest.\n\n" + apiSpecLine, nil | ||
| 18 | } | ||
| 19 | version := filepath.Base(distDir) | ||
| 20 | sums, err := parseSums(filepath.Join(distDir, "SHA256SUMS")) | ||
| 21 | if err != nil { | ||
| 22 | return "", fmt.Errorf("dist %s: %w", distDir, err) | ||
| 23 | } | ||
| 24 | entries, err := os.ReadDir(distDir) | ||
| 25 | if err != nil { | ||
| 26 | return "", err | ||
| 27 | } | ||
| 28 | |||
| 29 | var b strings.Builder | ||
| 30 | fmt.Fprintf(&b, "# downloads — %s\n\n", version) | ||
| 31 | b.WriteString("| file | size | sha256 |\n|---|---|---|\n") | ||
| 32 | names := make([]string, 0, len(entries)) | ||
| 33 | for _, e := range entries { | ||
| 34 | if !e.IsDir() { | ||
| 35 | names = append(names, e.Name()) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | sort.Strings(names) | ||
| 39 | for _, name := range names { | ||
| 40 | info, err := os.Stat(filepath.Join(distDir, name)) | ||
| 41 | if err != nil { | ||
| 42 | return "", err | ||
| 43 | } | ||
| 44 | // manifest.json and SHA256SUMS itself carry no checksum entry; an | ||
| 45 | // empty code span would render as literal backticks. | ||
| 46 | sha := "—" | ||
| 47 | if s := sums[name]; s != "" { | ||
| 48 | sha = "`" + s + "`" | ||
| 49 | } | ||
| 50 | fmt.Fprintf(&b, "| [%s](/dl/%s/%s) | %s | %s |\n", | ||
| 51 | name, version, name, humanSize(info.Size()), sha) | ||
| 52 | } | ||
| 53 | fmt.Fprintf(&b, "\nVerify after downloading (checksums: [SHA256SUMS](/dl/%s/SHA256SUMS)):\n\n", version) | ||
| 54 | b.WriteString(" sha256sum -c SHA256SUMS --ignore-missing\n") | ||
| 55 | b.WriteString("\n" + apiSpecLine) | ||
| 56 | return b.String(), nil | ||
| 57 | } | ||
| 58 | |||
| 59 | // apiSpecLine links the served API contract from the downloads page; the spec | ||
| 60 | // publishes at the site root in every build, dist or not. | ||
| 61 | const apiSpecLine = "[openapi.json](/openapi.json) — the server HTTP API, OpenAPI 3.1\n" | ||
| 62 | |||
| 63 | // parseSums reads sha256sum output: "<hex> <name>" per line. | ||
| 64 | func parseSums(path string) (map[string]string, error) { | ||
| 65 | raw, err := os.ReadFile(path) | ||
| 66 | if err != nil { | ||
| 67 | return nil, err | ||
| 68 | } | ||
| 69 | sums := map[string]string{} | ||
| 70 | for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { | ||
| 71 | fields := strings.Fields(line) | ||
| 72 | if len(fields) == 2 { | ||
| 73 | sums[strings.TrimPrefix(fields[1], "*")] = fields[0] | ||
| 74 | } | ||
| 75 | } | ||
| 76 | return sums, nil | ||
| 77 | } | ||
| 78 | |||
| 79 | func humanSize(n int64) string { | ||
| 80 | switch { | ||
| 81 | case n >= 1<<20: | ||
| 82 | return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) | ||
| 83 | case n >= 1<<10: | ||
| 84 | return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) | ||
| 85 | default: | ||
| 86 | return fmt.Sprintf("%d B", n) | ||
| 87 | } | ||
| 88 | } | ||
internal/site/dl_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,92 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func fixtureDist(t *testing.T) string { | ||
| 11 | t.Helper() | ||
| 12 | dist := filepath.Join(t.TempDir(), "v0.0.1") | ||
| 13 | if err := os.MkdirAll(dist, 0o755); err != nil { | ||
| 14 | t.Fatal(err) | ||
| 15 | } | ||
| 16 | files := map[string]string{ | ||
| 17 | "eitri_v0.0.1_linux_amd64.tar.gz": strings.Repeat("x", 2048), | ||
| 18 | "eitri-agent_linux_amd64": "binary", | ||
| 19 | "SHA256SUMS": "abc123 eitri_v0.0.1_linux_amd64.tar.gz\ndef456 eitri-agent_linux_amd64\n", | ||
| 20 | } | ||
| 21 | for name, body := range files { | ||
| 22 | if err := os.WriteFile(filepath.Join(dist, name), []byte(body), 0o644); err != nil { | ||
| 23 | t.Fatal(err) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | return dist | ||
| 27 | } | ||
| 28 | |||
| 29 | func TestDownloadsPageListsArtifacts(t *testing.T) { | ||
| 30 | md, err := downloadsMarkdown(fixtureDist(t)) | ||
| 31 | if err != nil { | ||
| 32 | t.Fatal(err) | ||
| 33 | } | ||
| 34 | for _, want := range []string{ | ||
| 35 | "[eitri_v0.0.1_linux_amd64.tar.gz](/dl/v0.0.1/eitri_v0.0.1_linux_amd64.tar.gz)", | ||
| 36 | "abc123", | ||
| 37 | "sha256sum -c SHA256SUMS", | ||
| 38 | "[SHA256SUMS](/dl/v0.0.1/SHA256SUMS)", | ||
| 39 | "[openapi.json](/openapi.json)", | ||
| 40 | } { | ||
| 41 | if !strings.Contains(md, want) { | ||
| 42 | t.Errorf("downloads page missing %q:\n%s", want, md) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | func TestDownloadsPageWithoutDist(t *testing.T) { | ||
| 48 | md, err := downloadsMarkdown("") | ||
| 49 | if err != nil { | ||
| 50 | t.Fatal(err) | ||
| 51 | } | ||
| 52 | if !strings.Contains(md, "No release is staged") { | ||
| 53 | t.Errorf("preview note missing:\n%s", md) | ||
| 54 | } | ||
| 55 | if !strings.Contains(md, "[openapi.json](/openapi.json)") { | ||
| 56 | t.Errorf("API spec link missing from preview:\n%s", md) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | func TestDownloadsPageRequiresSums(t *testing.T) { | ||
| 61 | dist := filepath.Join(t.TempDir(), "v0.0.1") | ||
| 62 | if err := os.MkdirAll(dist, 0o755); err != nil { | ||
| 63 | t.Fatal(err) | ||
| 64 | } | ||
| 65 | if _, err := downloadsMarkdown(dist); err == nil { | ||
| 66 | t.Fatal("want error for dist dir without SHA256SUMS") | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | func TestDownloadsPageUnsummedFilesGetPlaceholder(t *testing.T) { | ||
| 71 | dist := fixtureDist(t) | ||
| 72 | if err := os.WriteFile(filepath.Join(dist, "manifest.json"), []byte("{}"), 0o644); err != nil { | ||
| 73 | t.Fatal(err) | ||
| 74 | } | ||
| 75 | md, err := downloadsMarkdown(dist) | ||
| 76 | if err != nil { | ||
| 77 | t.Fatal(err) | ||
| 78 | } | ||
| 79 | if strings.Contains(md, "``") { | ||
| 80 | t.Errorf("empty sha rendered as bare backticks:\n%s", md) | ||
| 81 | } | ||
| 82 | if !strings.Contains(md, "| — |") { | ||
| 83 | t.Errorf("unsummed file missing placeholder:\n%s", md) | ||
| 84 | } | ||
| 85 | html, err := render([]byte(md), nil) | ||
| 86 | if err != nil { | ||
| 87 | t.Fatal(err) | ||
| 88 | } | ||
| 89 | if strings.Contains(string(html), "``") { | ||
| 90 | t.Errorf("stray backticks in rendered HTML:\n%s", html) | ||
| 91 | } | ||
| 92 | } | ||
internal/site/manifest.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,70 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/sha256" | ||
| 5 | "encoding/hex" | ||
| 6 | "fmt" | ||
| 7 | "io" | ||
| 8 | "net/url" | ||
| 9 | "os" | ||
| 10 | "path/filepath" | ||
| 11 | "regexp" | ||
| 12 | |||
| 13 | "github.com/a73x/eitri/internal/server/release" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // barePat matches the bare agent binaries the release stage drops in dist/: | ||
| 17 | // eitri-agent_<os>_<arch>. These are what the agent self-updater downloads. | ||
| 18 | var barePat = regexp.MustCompile(`^eitri-agent_([a-z0-9]+)_([a-z0-9]+)$`) | ||
| 19 | |||
| 20 | // BuildManifest scans distDir for bare eitri-agent binaries and produces the | ||
| 21 | // release manifest the server polls. Sharing release.Manifest with the | ||
| 22 | // consumer is deliberate: the wire contract lives in one type. | ||
| 23 | func BuildManifest(version, distDir, baseURL string) (release.Manifest, error) { | ||
| 24 | if version == "" { | ||
| 25 | return release.Manifest{}, fmt.Errorf("version required") | ||
| 26 | } | ||
| 27 | m := release.Manifest{ | ||
| 28 | Version: version, | ||
| 29 | Artifacts: map[string]map[string]release.Artifact{"eitri-agent": {}}, | ||
| 30 | } | ||
| 31 | entries, err := os.ReadDir(distDir) | ||
| 32 | if err != nil { | ||
| 33 | return release.Manifest{}, err | ||
| 34 | } | ||
| 35 | for _, e := range entries { | ||
| 36 | match := barePat.FindStringSubmatch(e.Name()) | ||
| 37 | if e.IsDir() || match == nil { | ||
| 38 | continue | ||
| 39 | } | ||
| 40 | sum, err := fileSHA256(filepath.Join(distDir, e.Name())) | ||
| 41 | if err != nil { | ||
| 42 | return release.Manifest{}, err | ||
| 43 | } | ||
| 44 | u, err := url.JoinPath(baseURL, e.Name()) | ||
| 45 | if err != nil { | ||
| 46 | return release.Manifest{}, err | ||
| 47 | } | ||
| 48 | m.Artifacts["eitri-agent"][match[1]+"/"+match[2]] = release.Artifact{ | ||
| 49 | URL: u, | ||
| 50 | SHA256: sum, | ||
| 51 | } | ||
| 52 | } | ||
| 53 | if len(m.Artifacts["eitri-agent"]) == 0 { | ||
| 54 | return release.Manifest{}, fmt.Errorf("no bare eitri-agent binaries in %s", distDir) | ||
| 55 | } | ||
| 56 | return m, nil | ||
| 57 | } | ||
| 58 | |||
| 59 | func fileSHA256(path string) (string, error) { | ||
| 60 | f, err := os.Open(path) | ||
| 61 | if err != nil { | ||
| 62 | return "", err | ||
| 63 | } | ||
| 64 | defer f.Close() | ||
| 65 | h := sha256.New() | ||
| 66 | if _, err := io.Copy(h, f); err != nil { | ||
| 67 | return "", err | ||
| 68 | } | ||
| 69 | return hex.EncodeToString(h.Sum(nil)), nil | ||
| 70 | } | ||
internal/site/manifest_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,86 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/sha256" | ||
| 5 | "encoding/hex" | ||
| 6 | "encoding/json" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "github.com/a73x/eitri/internal/server/release" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func TestBuildManifestFromBareBinaries(t *testing.T) { | ||
| 15 | dist := t.TempDir() | ||
| 16 | body := []byte("fake agent binary") | ||
| 17 | for _, name := range []string{"eitri-agent_linux_amd64", "eitri-agent_linux_arm64"} { | ||
| 18 | if err := os.WriteFile(filepath.Join(dist, name), body, 0o755); err != nil { | ||
| 19 | t.Fatal(err) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | // Non-bare files must be ignored. | ||
| 23 | if err := os.WriteFile(filepath.Join(dist, "eitri_v0.0.1_linux_amd64.tar.gz"), body, 0o644); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | |||
| 27 | m, err := BuildManifest("v0.0.1", dist, "https://eitri.sh/dl/v0.0.1") | ||
| 28 | if err != nil { | ||
| 29 | t.Fatal(err) | ||
| 30 | } | ||
| 31 | if m.Version != "v0.0.1" { | ||
| 32 | t.Errorf("version = %q", m.Version) | ||
| 33 | } | ||
| 34 | agents := m.Artifacts["eitri-agent"] | ||
| 35 | if len(agents) != 2 { | ||
| 36 | t.Fatalf("want 2 platforms, got %v", agents) | ||
| 37 | } | ||
| 38 | sum := sha256.Sum256(body) | ||
| 39 | want := release.Artifact{ | ||
| 40 | URL: "https://eitri.sh/dl/v0.0.1/eitri-agent_linux_amd64", | ||
| 41 | SHA256: hex.EncodeToString(sum[:]), | ||
| 42 | } | ||
| 43 | if agents["linux/amd64"] != want { | ||
| 44 | t.Errorf("linux/amd64 = %+v, want %+v", agents["linux/amd64"], want) | ||
| 45 | } | ||
| 46 | |||
| 47 | // Round-trip: what we emit is exactly what the server-side type parses. | ||
| 48 | raw, err := json.Marshal(m) | ||
| 49 | if err != nil { | ||
| 50 | t.Fatal(err) | ||
| 51 | } | ||
| 52 | var back release.Manifest | ||
| 53 | if err := json.Unmarshal(raw, &back); err != nil { | ||
| 54 | t.Fatal(err) | ||
| 55 | } | ||
| 56 | if back.Artifacts["eitri-agent"]["linux/arm64"].SHA256 != agents["linux/arm64"].SHA256 { | ||
| 57 | t.Error("round-trip through release.Manifest lost data") | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | func TestBuildManifestRequiresBinaries(t *testing.T) { | ||
| 62 | if _, err := BuildManifest("v0.0.1", t.TempDir(), "https://eitri.sh/dl/v0.0.1"); err == nil { | ||
| 63 | t.Fatal("want error when no bare eitri-agent binaries exist") | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | func TestBuildManifestRequiresVersion(t *testing.T) { | ||
| 68 | if _, err := BuildManifest("", t.TempDir(), "https://eitri.sh/dl/v0.0.1"); err == nil { | ||
| 69 | t.Fatal("want error for empty version") | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | func TestBuildManifestNormalizesBaseURL(t *testing.T) { | ||
| 74 | dist := t.TempDir() | ||
| 75 | if err := os.WriteFile(filepath.Join(dist, "eitri-agent_linux_amd64"), []byte("x"), 0o755); err != nil { | ||
| 76 | t.Fatal(err) | ||
| 77 | } | ||
| 78 | m, err := BuildManifest("v0.0.1", dist, "https://eitri.sh/dl/v0.0.1/") | ||
| 79 | if err != nil { | ||
| 80 | t.Fatal(err) | ||
| 81 | } | ||
| 82 | got := m.Artifacts["eitri-agent"]["linux/amd64"].URL | ||
| 83 | if got != "https://eitri.sh/dl/v0.0.1/eitri-agent_linux_amd64" { | ||
| 84 | t.Errorf("trailing-slash base not normalized: %q", got) | ||
| 85 | } | ||
| 86 | } | ||
internal/site/render.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,83 @@ | |||
| 1 | // Package site generates the eitri.sh static site: docs/*.md and a markdown | ||
| 2 | // landing page rendered through one HTML template, plus the downloads page | ||
| 3 | // and the agent-upgrade release manifest. Inter-doc links are rewritten to | ||
| 4 | // site paths; a link to a page that does not exist fails the build, which is | ||
| 5 | // the drift gate between repo docs and the published site. | ||
| 6 | package site | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "bytes" | ||
| 10 | "errors" | ||
| 11 | "fmt" | ||
| 12 | "strings" | ||
| 13 | |||
| 14 | "github.com/yuin/goldmark" | ||
| 15 | "github.com/yuin/goldmark/ast" | ||
| 16 | "github.com/yuin/goldmark/extension" | ||
| 17 | "github.com/yuin/goldmark/parser" | ||
| 18 | ghtml "github.com/yuin/goldmark/renderer/html" | ||
| 19 | "github.com/yuin/goldmark/text" | ||
| 20 | "github.com/yuin/goldmark/util" | ||
| 21 | ) | ||
| 22 | |||
| 23 | // linkRewriter rewrites relative markdown link destinations to their site | ||
| 24 | // paths via targets, collecting an error per destination that maps to no | ||
| 25 | // known page. External (scheme), site-absolute (/...), and fragment-only | ||
| 26 | // links pass through untouched. | ||
| 27 | type linkRewriter struct { | ||
| 28 | targets map[string]string | ||
| 29 | errs []error | ||
| 30 | } | ||
| 31 | |||
| 32 | func (r *linkRewriter) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) { | ||
| 33 | _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | ||
| 34 | if !entering { | ||
| 35 | return ast.WalkContinue, nil | ||
| 36 | } | ||
| 37 | var dest *[]byte | ||
| 38 | switch v := n.(type) { | ||
| 39 | case *ast.Link: | ||
| 40 | dest = &v.Destination | ||
| 41 | case *ast.Image: | ||
| 42 | dest = &v.Destination | ||
| 43 | default: | ||
| 44 | return ast.WalkContinue, nil | ||
| 45 | } | ||
| 46 | d := string(*dest) | ||
| 47 | if d == "" || strings.Contains(d, "://") || strings.HasPrefix(d, "mailto:") || | ||
| 48 | strings.HasPrefix(d, "/") || strings.HasPrefix(d, "#") { | ||
| 49 | return ast.WalkContinue, nil | ||
| 50 | } | ||
| 51 | path, frag, _ := strings.Cut(d, "#") | ||
| 52 | path = strings.TrimPrefix(path, "./") | ||
| 53 | u, ok := r.targets[path] | ||
| 54 | if !ok { | ||
| 55 | r.errs = append(r.errs, fmt.Errorf("link to unknown page %q", d)) | ||
| 56 | return ast.WalkContinue, nil | ||
| 57 | } | ||
| 58 | if frag != "" { | ||
| 59 | u += "#" + frag | ||
| 60 | } | ||
| 61 | *dest = []byte(u) | ||
| 62 | return ast.WalkContinue, nil | ||
| 63 | }) | ||
| 64 | } | ||
| 65 | |||
| 66 | // render converts markdown to HTML, rewriting internal links via targets. | ||
| 67 | // Any link to an unknown internal page is an error. | ||
| 68 | func render(src []byte, targets map[string]string) ([]byte, error) { | ||
| 69 | rw := &linkRewriter{targets: targets} | ||
| 70 | md := goldmark.New( | ||
| 71 | goldmark.WithExtensions(extension.GFM), | ||
| 72 | goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(rw, 100))), | ||
| 73 | goldmark.WithRendererOptions(ghtml.WithUnsafe()), | ||
| 74 | ) | ||
| 75 | var buf bytes.Buffer | ||
| 76 | if err := md.Convert(src, &buf); err != nil { | ||
| 77 | return nil, err | ||
| 78 | } | ||
| 79 | if len(rw.errs) > 0 { | ||
| 80 | return nil, errors.Join(rw.errs...) | ||
| 81 | } | ||
| 82 | return buf.Bytes(), nil | ||
| 83 | } | ||
internal/site/render_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,97 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | var testTargets = map[string]string{ | ||
| 9 | "README.md": "/docs/", | ||
| 10 | "ssh-access.md": "/docs/ssh-access/", | ||
| 11 | "../ROADMAP.md": "/docs/roadmap/", | ||
| 12 | "shape.html": "/docs/shape.html", | ||
| 13 | } | ||
| 14 | |||
| 15 | func TestRenderRewritesInternalLinks(t *testing.T) { | ||
| 16 | got, err := render([]byte("see [ssh](ssh-access.md) and [index](README.md)"), testTargets) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | html := string(got) | ||
| 21 | for _, want := range []string{`href="/docs/ssh-access/"`, `href="/docs/"`} { | ||
| 22 | if !strings.Contains(html, want) { | ||
| 23 | t.Errorf("output missing %s:\n%s", want, html) | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | func TestRenderPreservesFragments(t *testing.T) { | ||
| 29 | got, err := render([]byte("[a](ssh-access.md#gate)"), testTargets) | ||
| 30 | if err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | if !strings.Contains(string(got), `href="/docs/ssh-access/#gate"`) { | ||
| 34 | t.Errorf("fragment lost:\n%s", got) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | func TestRenderLeavesExternalAndAbsoluteAlone(t *testing.T) { | ||
| 39 | src := "[x](https://eitri.sh) [y](/dl/) [z](#local)" | ||
| 40 | got, err := render([]byte(src), testTargets) | ||
| 41 | if err != nil { | ||
| 42 | t.Fatal(err) | ||
| 43 | } | ||
| 44 | html := string(got) | ||
| 45 | for _, want := range []string{`href="https://eitri.sh"`, `href="/dl/"`, `href="#local"`} { | ||
| 46 | if !strings.Contains(html, want) { | ||
| 47 | t.Errorf("output missing %s:\n%s", want, html) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | func TestRenderFailsOnUnknownInternalLink(t *testing.T) { | ||
| 53 | _, err := render([]byte("[gone](no-such-doc.md)"), testTargets) | ||
| 54 | if err == nil || !strings.Contains(err.Error(), "no-such-doc.md") { | ||
| 55 | t.Fatalf("want broken-link error naming the target, got %v", err) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | func TestRenderTables(t *testing.T) { | ||
| 60 | got, err := render([]byte("| a | b |\n|---|---|\n| 1 | 2 |"), testTargets) | ||
| 61 | if err != nil { | ||
| 62 | t.Fatal(err) | ||
| 63 | } | ||
| 64 | if !strings.Contains(string(got), "<table>") { | ||
| 65 | t.Errorf("GFM tables not rendered:\n%s", got) | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | func TestRenderRewritesParentRelativeLinks(t *testing.T) { | ||
| 70 | got, err := render([]byte("[r](../ROADMAP.md)"), testTargets) | ||
| 71 | if err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if !strings.Contains(string(got), `href="/docs/roadmap/"`) { | ||
| 75 | t.Errorf("parent-relative link not rewritten:\n%s", got) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | func TestRenderNormalizesDotSlashPrefix(t *testing.T) { | ||
| 80 | got, err := render([]byte("[s](./ssh-access.md)"), testTargets) | ||
| 81 | if err != nil { | ||
| 82 | t.Fatal(err) | ||
| 83 | } | ||
| 84 | if !strings.Contains(string(got), `href="/docs/ssh-access/"`) { | ||
| 85 | t.Errorf("./-prefixed link not rewritten:\n%s", got) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | func TestRenderRewritesImageDestinations(t *testing.T) { | ||
| 90 | got, err := render([]byte(""), testTargets) | ||
| 91 | if err != nil { | ||
| 92 | t.Fatal(err) | ||
| 93 | } | ||
| 94 | if !strings.Contains(string(got), `src="/docs/shape.html"`) { | ||
| 95 | t.Errorf("image destination not rewritten:\n%s", got) | ||
| 96 | } | ||
| 97 | } | ||
internal/site/site.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,159 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "html/template" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // pages is the published doc set: how-to material for people running or using | ||
| 11 | // a fleet. The rest of docs/ (architecture, ethos, decisions, the shape | ||
| 12 | // diagram, superpowers/) is for people working on eitri and never publishes. | ||
| 13 | // Build hard-fails if any listed doc is missing. | ||
| 14 | var pages = []string{ | ||
| 15 | "quickstart", | ||
| 16 | "ssh-access", | ||
| 17 | "mcp", | ||
| 18 | "upgrade", | ||
| 19 | "cert-rotation", | ||
| 20 | "credential-revocation", | ||
| 21 | "faq", | ||
| 22 | } | ||
| 23 | |||
| 24 | // Config locates the generator's inputs and output. | ||
| 25 | type Config struct { | ||
| 26 | DocsDir string // repo docs/ — markdown sources for the pages list | ||
| 27 | SiteDir string // site/ — index.md, docs.md, template.html, style.css | ||
| 28 | DistDir string // optional dist/<version> with release artifacts; "" renders a docs-only preview | ||
| 29 | OutDir string // webroot to emit | ||
| 30 | } | ||
| 31 | |||
| 32 | type pageData struct { | ||
| 33 | Title string | ||
| 34 | Section string // "home" | "docs" | "dl" — which nav entry is active | ||
| 35 | Content template.HTML | ||
| 36 | } | ||
| 37 | |||
| 38 | // Build renders the whole site into cfg.OutDir. | ||
| 39 | func Build(cfg Config) error { | ||
| 40 | tmplSrc, err := os.ReadFile(filepath.Join(cfg.SiteDir, "template.html")) | ||
| 41 | if err != nil { | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | tmpl, err := template.New("page").Parse(string(tmplSrc)) | ||
| 45 | if err != nil { | ||
| 46 | return err | ||
| 47 | } | ||
| 48 | |||
| 49 | targets := linkTargets() | ||
| 50 | |||
| 51 | page := func(outPath string, d pageData) error { | ||
| 52 | if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { | ||
| 53 | return err | ||
| 54 | } | ||
| 55 | f, err := os.Create(outPath) | ||
| 56 | if err != nil { | ||
| 57 | return err | ||
| 58 | } | ||
| 59 | if err := tmpl.Execute(f, d); err != nil { | ||
| 60 | f.Close() | ||
| 61 | return err | ||
| 62 | } | ||
| 63 | return f.Close() | ||
| 64 | } | ||
| 65 | renderFile := func(src string) (template.HTML, error) { | ||
| 66 | b, err := os.ReadFile(src) | ||
| 67 | if err != nil { | ||
| 68 | return "", err | ||
| 69 | } | ||
| 70 | h, err := render(b, targets) | ||
| 71 | if err != nil { | ||
| 72 | return "", fmt.Errorf("%s: %w", src, err) | ||
| 73 | } | ||
| 74 | return template.HTML(h), nil | ||
| 75 | } | ||
| 76 | |||
| 77 | // Landing. | ||
| 78 | content, err := renderFile(filepath.Join(cfg.SiteDir, "index.md")) | ||
| 79 | if err != nil { | ||
| 80 | return err | ||
| 81 | } | ||
| 82 | if err := page(filepath.Join(cfg.OutDir, "index.html"), | ||
| 83 | pageData{Title: "eitri", Section: "home", Content: content}); err != nil { | ||
| 84 | return err | ||
| 85 | } | ||
| 86 | |||
| 87 | // Docs index (site-owned: the published set is a curated subset of docs/, | ||
| 88 | // so the repo's own docs/README.md would dangle links here) + one page per | ||
| 89 | // published doc + the roadmap. | ||
| 90 | if content, err = renderFile(filepath.Join(cfg.SiteDir, "docs.md")); err != nil { | ||
| 91 | return err | ||
| 92 | } | ||
| 93 | if err := page(filepath.Join(cfg.OutDir, "docs", "index.html"), | ||
| 94 | pageData{Title: "eitri — docs", Section: "docs", Content: content}); err != nil { | ||
| 95 | return err | ||
| 96 | } | ||
| 97 | for _, slug := range pages { | ||
| 98 | if content, err = renderFile(filepath.Join(cfg.DocsDir, slug+".md")); err != nil { | ||
| 99 | return err | ||
| 100 | } | ||
| 101 | if err := page(filepath.Join(cfg.OutDir, "docs", slug, "index.html"), | ||
| 102 | pageData{Title: "eitri — " + slug, Section: "docs", Content: content}); err != nil { | ||
| 103 | return err | ||
| 104 | } | ||
| 105 | } | ||
| 106 | if content, err = renderFile(filepath.Join(cfg.DocsDir, "..", "ROADMAP.md")); err != nil { | ||
| 107 | return err | ||
| 108 | } | ||
| 109 | if err := page(filepath.Join(cfg.OutDir, "docs", "roadmap", "index.html"), | ||
| 110 | pageData{Title: "eitri — roadmap", Section: "docs", Content: content}); err != nil { | ||
| 111 | return err | ||
| 112 | } | ||
| 113 | |||
| 114 | // Downloads page. | ||
| 115 | dlMD, err := downloadsMarkdown(cfg.DistDir) | ||
| 116 | if err != nil { | ||
| 117 | return err | ||
| 118 | } | ||
| 119 | h, err := render([]byte(dlMD), targets) | ||
| 120 | if err != nil { | ||
| 121 | return err | ||
| 122 | } | ||
| 123 | if err := page(filepath.Join(cfg.OutDir, "dl", "index.html"), | ||
| 124 | pageData{Title: "eitri — downloads", Section: "dl", Content: template.HTML(h)}); err != nil { | ||
| 125 | return err | ||
| 126 | } | ||
| 127 | |||
| 128 | // The API contract publishes verbatim at the site root when the docs | ||
| 129 | // tree carries it (site-check enforces presence in the real tree; | ||
| 130 | // synthetic docs trees may omit it). | ||
| 131 | spec, err := os.ReadFile(filepath.Join(cfg.DocsDir, "openapi.json")) | ||
| 132 | switch { | ||
| 133 | case err == nil: | ||
| 134 | if err := os.WriteFile(filepath.Join(cfg.OutDir, "openapi.json"), spec, 0o644); err != nil { | ||
| 135 | return err | ||
| 136 | } | ||
| 137 | case !os.IsNotExist(err): | ||
| 138 | return err | ||
| 139 | } | ||
| 140 | |||
| 141 | // The one stylesheet. | ||
| 142 | css, err := os.ReadFile(filepath.Join(cfg.SiteDir, "style.css")) | ||
| 143 | if err != nil { | ||
| 144 | return err | ||
| 145 | } | ||
| 146 | return os.WriteFile(filepath.Join(cfg.OutDir, "style.css"), css, 0o644) | ||
| 147 | } | ||
| 148 | |||
| 149 | // linkTargets maps markdown link destinations (as written in the sources) to | ||
| 150 | // published site paths — the vocabulary the link rewriter validates against. | ||
| 151 | // A doc outside the published set is deliberately absent: linking it from a | ||
| 152 | // published page is a build failure, not a dangling link. | ||
| 153 | func linkTargets() map[string]string { | ||
| 154 | t := map[string]string{"../ROADMAP.md": "/docs/roadmap/"} | ||
| 155 | for _, s := range pages { | ||
| 156 | t[s+".md"] = "/docs/" + s + "/" | ||
| 157 | } | ||
| 158 | return t | ||
| 159 | } | ||
internal/site/site_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,159 @@ | |||
| 1 | package site | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // writeFixture lays down a minimal docs/ + site/ tree covering every | ||
| 11 | // published page, plus repo-internal material that must never publish. | ||
| 12 | // Returns (root, docs, siteDir). | ||
| 13 | func writeFixture(t *testing.T) (string, string, string) { | ||
| 14 | t.Helper() | ||
| 15 | root := t.TempDir() | ||
| 16 | docs := filepath.Join(root, "docs") | ||
| 17 | siteDir := filepath.Join(root, "site") | ||
| 18 | for _, d := range []string{docs, siteDir, filepath.Join(docs, "superpowers")} { | ||
| 19 | if err := os.MkdirAll(d, 0o755); err != nil { | ||
| 20 | t.Fatal(err) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | write := func(path, body string) { | ||
| 24 | t.Helper() | ||
| 25 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | ||
| 26 | t.Fatal(err) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | for _, slug := range pages { | ||
| 30 | write(filepath.Join(docs, slug+".md"), "# "+slug+"\n\nbody\n") | ||
| 31 | } | ||
| 32 | // Repo-internal docs sitting alongside the published set. | ||
| 33 | write(filepath.Join(docs, "architecture.md"), "# internal\n") | ||
| 34 | write(filepath.Join(docs, "shape.html"), "<html>internal</html>") | ||
| 35 | write(filepath.Join(docs, "superpowers", "secret.md"), "# nope\n") | ||
| 36 | write(filepath.Join(root, "ROADMAP.md"), "# roadmap\n") | ||
| 37 | write(filepath.Join(siteDir, "index.md"), "# eitri\n\n[q](quickstart.md)\n") | ||
| 38 | write(filepath.Join(siteDir, "docs.md"), | ||
| 39 | "# docs\n\n- [q](quickstart.md)\n- [r](../ROADMAP.md)\n") | ||
| 40 | write(filepath.Join(siteDir, "template.html"), | ||
| 41 | `<title>{{.Title}}</title><nav data-s="{{.Section}}"></nav>{{.Content}}`) | ||
| 42 | write(filepath.Join(siteDir, "style.css"), "body{}") | ||
| 43 | return root, docs, siteDir | ||
| 44 | } | ||
| 45 | |||
| 46 | // buildTree runs Build over the fixture and returns the output dir. | ||
| 47 | func buildTree(t *testing.T, distDir string) string { | ||
| 48 | t.Helper() | ||
| 49 | root, docs, siteDir := writeFixture(t) | ||
| 50 | out := filepath.Join(root, "out") | ||
| 51 | if err := Build(Config{DocsDir: docs, SiteDir: siteDir, DistDir: distDir, OutDir: out}); err != nil { | ||
| 52 | t.Fatal(err) | ||
| 53 | } | ||
| 54 | return out | ||
| 55 | } | ||
| 56 | |||
| 57 | func read(t *testing.T, path string) string { | ||
| 58 | t.Helper() | ||
| 59 | b, err := os.ReadFile(path) | ||
| 60 | if err != nil { | ||
| 61 | t.Fatal(err) | ||
| 62 | } | ||
| 63 | return string(b) | ||
| 64 | } | ||
| 65 | |||
| 66 | func TestBuildEmitsPublishedTree(t *testing.T) { | ||
| 67 | out := buildTree(t, "") | ||
| 68 | want := []string{"index.html", "style.css", "docs/index.html", "docs/roadmap/index.html", "dl/index.html"} | ||
| 69 | for _, slug := range pages { | ||
| 70 | want = append(want, "docs/"+slug+"/index.html") | ||
| 71 | } | ||
| 72 | for _, p := range want { | ||
| 73 | if _, err := os.Stat(filepath.Join(out, p)); err != nil { | ||
| 74 | t.Errorf("missing %s: %v", p, err) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | func TestBuildNeverPublishesInternalDocs(t *testing.T) { | ||
| 80 | out := buildTree(t, "") | ||
| 81 | for _, p := range []string{ | ||
| 82 | "docs/architecture", "docs/architecture/index.html", | ||
| 83 | "docs/shape.html", | ||
| 84 | "docs/superpowers", "docs/secret", | ||
| 85 | } { | ||
| 86 | if _, err := os.Stat(filepath.Join(out, p)); !os.IsNotExist(err) { | ||
| 87 | t.Errorf("%s must not be published (stat err = %v)", p, err) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | func TestBuildRewritesDocLinks(t *testing.T) { | ||
| 93 | out := buildTree(t, "") | ||
| 94 | idx := read(t, filepath.Join(out, "docs", "index.html")) | ||
| 95 | for _, want := range []string{`href="/docs/quickstart/"`, `href="/docs/roadmap/"`} { | ||
| 96 | if !strings.Contains(idx, want) { | ||
| 97 | t.Errorf("docs index missing %s:\n%s", want, idx) | ||
| 98 | } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | func TestBuildSectionsAndTitles(t *testing.T) { | ||
| 103 | out := buildTree(t, "") | ||
| 104 | if got := read(t, filepath.Join(out, "index.html")); !strings.Contains(got, `data-s="home"`) { | ||
| 105 | t.Errorf("landing section wrong:\n%s", got) | ||
| 106 | } | ||
| 107 | qs := read(t, filepath.Join(out, "docs", "quickstart", "index.html")) | ||
| 108 | if !strings.Contains(qs, `data-s="docs"`) || !strings.Contains(qs, "<title>eitri — quickstart</title>") { | ||
| 109 | t.Errorf("doc page section/title wrong:\n%s", qs) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | func TestBuildPublishesAPISpec(t *testing.T) { | ||
| 114 | root, docs, siteDir := writeFixture(t) | ||
| 115 | spec := `{"openapi":"3.1.0"}` | ||
| 116 | if err := os.WriteFile(filepath.Join(docs, "openapi.json"), []byte(spec), 0o644); err != nil { | ||
| 117 | t.Fatal(err) | ||
| 118 | } | ||
| 119 | out := filepath.Join(root, "out") | ||
| 120 | if err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: out}); err != nil { | ||
| 121 | t.Fatal(err) | ||
| 122 | } | ||
| 123 | if got := read(t, filepath.Join(out, "openapi.json")); got != spec { | ||
| 124 | t.Errorf("published spec differs from source: got %q, want %q", got, spec) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | func TestBuildWithoutAPISpec(t *testing.T) { | ||
| 129 | // Synthetic docs trees without a spec build fine — they just publish none. | ||
| 130 | out := buildTree(t, "") | ||
| 131 | if _, err := os.Stat(filepath.Join(out, "openapi.json")); !os.IsNotExist(err) { | ||
| 132 | t.Errorf("openapi.json must not be published without a source (stat err = %v)", err) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | func TestBuildFailsOnLinkToUnpublishedDoc(t *testing.T) { | ||
| 137 | root, docs, siteDir := writeFixture(t) | ||
| 138 | // A published doc linking a repo-internal doc must fail the build, not | ||
| 139 | // ship a dangling link. | ||
| 140 | if err := os.WriteFile(filepath.Join(docs, "ssh-access.md"), | ||
| 141 | []byte("[why](architecture.md)\n"), 0o644); err != nil { | ||
| 142 | t.Fatal(err) | ||
| 143 | } | ||
| 144 | err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: filepath.Join(root, "out")}) | ||
| 145 | if err == nil || !strings.Contains(err.Error(), "architecture.md") { | ||
| 146 | t.Fatalf("want unpublished-link failure naming architecture.md, got %v", err) | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | func TestBuildFailsOnMissingPublishedDoc(t *testing.T) { | ||
| 151 | root, docs, siteDir := writeFixture(t) | ||
| 152 | if err := os.Remove(filepath.Join(docs, "quickstart.md")); err != nil { | ||
| 153 | t.Fatal(err) | ||
| 154 | } | ||
| 155 | err := Build(Config{DocsDir: docs, SiteDir: siteDir, OutDir: filepath.Join(root, "out")}) | ||
| 156 | if err == nil { | ||
| 157 | t.Fatal("want failure when a published doc is missing") | ||
| 158 | } | ||
| 159 | } | ||
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -37,6 +37,7 @@ declare -A FLOOR=( | |||
| 37 | [internal/server/web]=90 | 37 | [internal/server/web]=90 |
| 38 | [internal/transport]=77 | 38 | [internal/transport]=77 |
| 39 | [internal/shape]=88 | 39 | [internal/shape]=88 |
| 40 | [internal/site]=80 | ||
| 40 | ) | 41 | ) |
| 41 | 42 | ||
| 42 | profile="$(mktemp)" | 43 | profile="$(mktemp)" |
scripts/deploy.env.example
| Old | New | ||
|---|---|---|---|
| @@ -61,3 +61,14 @@ FIRMWARE="/usr/share/eitri/CLOUDHV.fd" | |||
| 61 | # Host resource caps live here (0/unset = offer the whole machine): reserve | 61 | # Host resource caps live here (0/unset = offer the whole machine): reserve |
| 62 | # headroom by capping the CPU/mem/disk the agent advertises AND enforces. | 62 | # headroom by capping the CPU/mem/disk the agent advertises AND enforces. |
| 63 | # AGENT_EXTRA_FLAGS="--max-vcpus 8 --max-mem-mb 16384 --max-disk-gb 200" | 63 | # AGENT_EXTRA_FLAGS="--max-vcpus 8 --max-mem-mb 16384 --max-disk-gb 200" |
| 64 | |||
| 65 | # ── eitri.sh site image (make site-image) ───────────────────────────────────── | ||
| 66 | # Registry/repo for the static-site image; tagged with the release version. | ||
| 67 | # Required for `make site-image` (everything else in this file ignores it). | ||
| 68 | # SITE_IMAGE="registry.example.com/eitri-site" | ||
| 69 | # Image platform for the site image; set to your cluster's node arch. | ||
| 70 | # SITE_PLATFORM="linux/arm64" | ||
| 71 | # Guest firmware ships inside dist/<version> (manifested, sha-pinned) rather | ||
| 72 | # than staged by the site image. `make release` reads FIRMWARE_SRC — a local | ||
| 73 | # CLOUDHV.fd to mirror in — defaulting to $HOME/.cache/eitri/CLOUDHV.fd if | ||
| 74 | # present; see scripts/release.sh. | ||
scripts/release.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,73 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: | ||
| 3 | # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit | ||
| 4 | # eitri-ssh_<v>.tar.gz client bundle: eitri-ssh + eitri-ca (portable bash) | ||
| 5 | # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent | ||
| 6 | # self-updater downloads and sha-verifies | ||
| 7 | # SHA256SUMS over everything above | ||
| 8 | # manifest.json agent-upgrade manifest (shared type | ||
| 9 | # with internal/server/release) | ||
| 10 | # | ||
| 11 | # MANIFEST_BASE (env, optional) overrides the URL base written into | ||
| 12 | # manifest.json — default https://eitri.sh/dl/<version>; set it for staging. | ||
| 13 | # | ||
| 14 | # Releases come from clean tagged trees: a -dirty version refuses outright | ||
| 15 | # (the agent-upgrade path compares versions numerically, so an unparsable | ||
| 16 | # version silently disables the upgrade button fleet-wide); an untagged HEAD | ||
| 17 | # warns but proceeds, for staging runs. | ||
| 18 | set -euo pipefail | ||
| 19 | cd "$(dirname "$0")/.." | ||
| 20 | |||
| 21 | VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" | ||
| 22 | case "$VERSION" in | ||
| 23 | *-dirty|dev) | ||
| 24 | echo "release: refusing to build from a dirty/untracked tree ($VERSION)" >&2 | ||
| 25 | exit 1 ;; | ||
| 26 | esac | ||
| 27 | if ! git describe --tags --exact-match >/dev/null 2>&1; then | ||
| 28 | echo "release: WARNING — HEAD is not a tag ($VERSION); agents never upgrade to unparsable versions" >&2 | ||
| 29 | fi | ||
| 30 | |||
| 31 | [ -x hack/eitri-ssh ] && [ -x hack/eitri-ca ] || { | ||
| 32 | echo "release: hack/eitri-ssh or hack/eitri-ca missing/not executable (client bundle)" >&2 | ||
| 33 | exit 1 | ||
| 34 | } | ||
| 35 | |||
| 36 | LDFLAGS="-X github.com/a73x/eitri/internal/version.Version=$VERSION" | ||
| 37 | OUT="dist/$VERSION" | ||
| 38 | rm -rf "$OUT" | ||
| 39 | mkdir -p "$OUT" | ||
| 40 | |||
| 41 | STAGE_ROOT="$(mktemp -d)" | ||
| 42 | trap 'rm -rf "$STAGE_ROOT"' EXIT | ||
| 43 | |||
| 44 | for arch in amd64 arm64; do | ||
| 45 | bundle="eitri_${VERSION}_linux_${arch}" | ||
| 46 | stage="$STAGE_ROOT/$arch" | ||
| 47 | mkdir -p "$stage/$bundle" | ||
| 48 | echo "==> building linux/$arch" | ||
| 49 | CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \ | ||
| 50 | -o "$stage/$bundle/eitri-server" ./cmd/eitri-server | ||
| 51 | CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "$LDFLAGS" \ | ||
| 52 | -o "$stage/$bundle/eitri-agent" ./cmd/eitri-agent | ||
| 53 | cp scripts/eitri-agent.service "$stage/$bundle/" | ||
| 54 | tar -C "$stage" -czf "$OUT/$bundle.tar.gz" "$bundle" | ||
| 55 | cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" | ||
| 56 | done | ||
| 57 | |||
| 58 | stage="$STAGE_ROOT/ssh" | ||
| 59 | mkdir -p "$stage/eitri-ssh_$VERSION" | ||
| 60 | cp hack/eitri-ssh hack/eitri-ca "$stage/eitri-ssh_$VERSION/" | ||
| 61 | tar -C "$stage" -czf "$OUT/eitri-ssh_$VERSION.tar.gz" "eitri-ssh_$VERSION" | ||
| 62 | |||
| 63 | # The API contract rides in the release too (drift-gated in ci, so the | ||
| 64 | # committed copy is authoritative). | ||
| 65 | cp docs/openapi.json "$OUT/openapi.json" | ||
| 66 | |||
| 67 | (cd "$OUT" && LC_ALL=C sha256sum -- * > SHA256SUMS) | ||
| 68 | |||
| 69 | go run ./cmd/eitri-site manifest -version "$VERSION" -dist "$OUT" \ | ||
| 70 | -base "${MANIFEST_BASE:-https://eitri.sh/dl/$VERSION}" | ||
| 71 | |||
| 72 | echo "==> release staged in $OUT" | ||
| 73 | ls -lh "$OUT" | ||
scripts/site-image.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,33 @@ | |||
| 1 | #!/usr/bin/env bash | ||
| 2 | # Assemble the eitri.sh webroot and build/push the site image. | ||
| 3 | # | ||
| 4 | # Reads deploy.env (same file scripts/deploy.sh uses): | ||
| 5 | # SITE_IMAGE registry/repo to push, e.g. registry.example/eitri-site (required) | ||
| 6 | # SITE_PLATFORM image platform, e.g. linux/arm64 (optional; the Dockerfile | ||
| 7 | # is COPY-only, so cross-building needs no emulation) | ||
| 8 | # | ||
| 9 | # Expects `make release` (dist/<version>) and `make site` (site/dist) to have | ||
| 10 | # run — the Makefile's site-image target orders all three. Firmware and the | ||
| 11 | # pinned cloud-hypervisor ship inside dist/<version> itself (see | ||
| 12 | # scripts/release.sh), so there is nothing extra to stage here. | ||
| 13 | set -euo pipefail | ||
| 14 | cd "$(dirname "$0")/.." | ||
| 15 | |||
| 16 | ENV_FILE="${EITRI_DEPLOY_ENV:-$HOME/eitri-deploy/deploy.env}" | ||
| 17 | # shellcheck disable=SC1090 | ||
| 18 | [ -f "$ENV_FILE" ] && . "$ENV_FILE" | ||
| 19 | : "${SITE_IMAGE:?site-image: set SITE_IMAGE in $ENV_FILE}" | ||
| 20 | |||
| 21 | VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" | ||
| 22 | [ -d "dist/$VERSION" ] || { echo "site-image: dist/$VERSION missing — run make release" >&2; exit 1; } | ||
| 23 | [ -f site/dist/index.html ] || { echo "site-image: site/dist missing — run make site" >&2; exit 1; } | ||
| 24 | |||
| 25 | # Stage /dl: versioned artifacts + the latest symlink. | ||
| 26 | rm -rf site/dist/dl/v* site/dist/dl/latest | ||
| 27 | mkdir -p site/dist/dl | ||
| 28 | cp -r "dist/$VERSION" "site/dist/dl/$VERSION" | ||
| 29 | ln -sfn "$VERSION" site/dist/dl/latest | ||
| 30 | |||
| 31 | docker build ${SITE_PLATFORM:+--platform "$SITE_PLATFORM"} -f site/Dockerfile -t "$SITE_IMAGE:$VERSION" . | ||
| 32 | docker push "$SITE_IMAGE:$VERSION" | ||
| 33 | echo "site-image: pushed $SITE_IMAGE:$VERSION — roll it out on k8s manually" | ||
site/Dockerfile
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,3 @@ | |||
| 1 | FROM docker.io/library/nginx:alpine | ||
| 2 | COPY site/nginx.conf /etc/nginx/conf.d/default.conf | ||
| 3 | COPY site/dist/ /usr/share/nginx/html/ | ||
site/docs.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,29 @@ | |||
| 1 | # docs | ||
| 2 | |||
| 3 | **Get started** | ||
| 4 | |||
| 5 | - [quickstart](quickstart.md) — zero to a first VM on one host, then a | ||
| 6 | second host | ||
| 7 | |||
| 8 | **Use a fleet** | ||
| 9 | |||
| 10 | - [ssh access](ssh-access.md) — reaching a VM through the jump gate with | ||
| 11 | your own keys | ||
| 12 | - [mcp](mcp.md) — let an AI agent create and drive VMs | ||
| 13 | |||
| 14 | **Run a fleet** | ||
| 15 | |||
| 16 | - [upgrading](upgrade.md) — agents from the console; the server; | ||
| 17 | cloud-hypervisor | ||
| 18 | - [cert rotation](cert-rotation.md) — rotating the server certificate | ||
| 19 | without an outage | ||
| 20 | - [credential revocation](credential-revocation.md) — the levers when | ||
| 21 | something leaks | ||
| 22 | |||
| 23 | **FAQ** | ||
| 24 | |||
| 25 | - [faq](faq.md) — networking, and other sharp edges | ||
| 26 | |||
| 27 | **Where it's going** | ||
| 28 | |||
| 29 | - [roadmap](../ROADMAP.md) | ||
site/index.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,27 @@ | |||
| 1 | # eitri — the cloud you already own | ||
| 2 | |||
| 3 | *The ease of use of the cloud, backed by your own hardware* | ||
| 4 | |||
| 5 | ## DESCRIPTION | ||
| 6 | |||
| 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 | ||
| 9 | and they serve VMs the way a cloud does, without the bill. No account, no | ||
| 10 | landlord. | ||
| 11 | |||
| 12 | 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 | ||
| 14 | any of it when you're done. | ||
| 15 | |||
| 16 | ## HOW IT WORKS | ||
| 17 | |||
| 18 | Run the server on one box; join the rest with one command each. Click | ||
| 19 | **+ Create VM** and a fresh VM boots in seconds — open its console in the | ||
| 20 | browser, or SSH in with certificates signed by your own CA (eitri never | ||
| 21 | holds your keys). | ||
| 22 | |||
| 23 | ## GETTING IT | ||
| 24 | |||
| 25 | Prebuilt tarballs and checksums: [downloads](/dl/). No installer — unpack, | ||
| 26 | run, done. The [quickstart](quickstart.md) takes you from zero to a first | ||
| 27 | VM; the [FAQ](faq.md) covers the sharp edges. | ||
site/nginx.conf
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,25 @@ | |||
| 1 | server { | ||
| 2 | listen 8080; | ||
| 3 | server_name _; | ||
| 4 | root /usr/share/nginx/html; | ||
| 5 | index index.html; | ||
| 6 | |||
| 7 | # The moving alias: a stale cached manifest would mask releases from | ||
| 8 | # every fleet (servers re-poll it daily). | ||
| 9 | location ^~ /dl/latest/ { | ||
| 10 | autoindex on; | ||
| 11 | add_header Cache-Control "no-cache"; | ||
| 12 | } | ||
| 13 | |||
| 14 | # Versioned artifacts are content-addressed by SHA256SUMS: immutable. | ||
| 15 | # NB: each location sets its own add_header — nginx location-level | ||
| 16 | # add_header REPLACES inherited ones, so add new headers everywhere. | ||
| 17 | location ~ ^/dl/v[0-9] { | ||
| 18 | autoindex on; | ||
| 19 | add_header Cache-Control "public, max-age=31536000, immutable"; | ||
| 20 | } | ||
| 21 | |||
| 22 | location / { | ||
| 23 | add_header Cache-Control "no-cache"; | ||
| 24 | } | ||
| 25 | } | ||
site/style.css
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,36 @@ | |||
| 1 | * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| 2 | |||
| 3 | body { | ||
| 4 | font-family: monospace; | ||
| 5 | max-width: 72ch; | ||
| 6 | margin: 0 auto; | ||
| 7 | padding: 1em; | ||
| 8 | line-height: 1.5; | ||
| 9 | } | ||
| 10 | |||
| 11 | a { color: inherit; } | ||
| 12 | |||
| 13 | nav { margin-bottom: 2em; } | ||
| 14 | nav a { margin-right: 1em; } | ||
| 15 | nav a.active { font-weight: bold; text-decoration: none; } | ||
| 16 | |||
| 17 | h1 { margin-bottom: 0.5em; } | ||
| 18 | h2 { margin-top: 1.5em; margin-bottom: 0.5em; } | ||
| 19 | h3 { margin-top: 1em; margin-bottom: 0.5em; } | ||
| 20 | |||
| 21 | p, ul, ol { margin-bottom: 1em; } | ||
| 22 | ul, ol { padding-left: 2em; } | ||
| 23 | li { margin-bottom: 0.25em; } | ||
| 24 | |||
| 25 | pre { | ||
| 26 | padding: 1em; | ||
| 27 | margin-bottom: 1em; | ||
| 28 | overflow-x: auto; | ||
| 29 | border: 1px solid; | ||
| 30 | } | ||
| 31 | |||
| 32 | table { border-collapse: collapse; margin-bottom: 1em; } | ||
| 33 | th, td { border: 1px solid; padding: 0.25em 0.75em; text-align: left; } | ||
| 34 | |||
| 35 | code { word-break: break-all; } | ||
| 36 | pre code { word-break: normal; } | ||
site/template.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,19 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | <title>{{.Title}}</title> | ||
| 7 | <link rel="stylesheet" href="/style.css"> | ||
| 8 | </head> | ||
| 9 | <body> | ||
| 10 | |||
| 11 | <nav> | ||
| 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> | ||
| 14 | <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a> | ||
| 15 | </nav> | ||
| 16 | |||
| 17 | {{.Content}} | ||
| 18 | </body> | ||
| 19 | </html> | ||