9101e104
feat(agent): bootstrap cloud-hypervisor and the guest firmware
a73x 2026-07-26 15:44
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -152,7 +152,7 @@ site-check: | |||
| 152 | done && echo "site-check: ok" | 152 | done && echo "site-check: ok" |
| 153 | 153 | ||
| 154 | # Build and push the eitri.sh site image (nginx + site + /dl artifacts). | 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. | 155 | # Needs SITE_IMAGE in deploy.env. |
| 156 | # Rollout on k8s is manual and stays outside the repo. | 156 | # Rollout on k8s is manual and stays outside the repo. |
| 157 | site-image: | 157 | site-image: |
| 158 | $(MAKE) release | 158 | $(MAKE) release |
cmd/eitri-agent/main.go
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | "syscall" | 15 | "syscall" |
| 16 | "time" | 16 | "time" |
| 17 | 17 | ||
| 18 | "github.com/a73x/eitri/internal/agent/bootstrap" | ||
| 18 | "github.com/a73x/eitri/internal/agent/cloudhv" | 19 | "github.com/a73x/eitri/internal/agent/cloudhv" |
| 19 | "github.com/a73x/eitri/internal/agent/enrollclient" | 20 | "github.com/a73x/eitri/internal/agent/enrollclient" |
| 20 | "github.com/a73x/eitri/internal/agent/imagecache" | 21 | "github.com/a73x/eitri/internal/agent/imagecache" |
| @@ -32,6 +33,7 @@ import ( | |||
| 32 | // agentConfig carries runAgent's wiring, replacing a long positional list. | 33 | // agentConfig carries runAgent's wiring, replacing a long positional list. |
| 33 | type agentConfig struct { | 34 | type agentConfig struct { |
| 34 | StateDir, CHBin, Firmware string | 35 | StateDir, CHBin, Firmware string |
| 36 | BootstrapURL string | ||
| 35 | TombstoneGrace, VanishGrace time.Duration | 37 | TombstoneGrace, VanishGrace time.Duration |
| 36 | VMTimeout time.Duration | 38 | VMTimeout time.Duration |
| 37 | ImageCacheMaxGB int64 | 39 | ImageCacheMaxGB int64 |
| @@ -51,6 +53,7 @@ func main() { | |||
| 51 | stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") | 53 | stateDir := flag.String("state-dir", "/var/lib/eitri-agent", "agent state directory") |
| 52 | chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") | 54 | chBin := flag.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") |
| 53 | firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") | 55 | firmware := flag.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") |
| 56 | bootstrapURL := flag.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)") | ||
| 54 | tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") | 57 | tombstoneGrace := flag.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") |
| 55 | vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") | 58 | vanishGrace := flag.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") |
| 56 | vmTimeout := flag.Duration("vm-timeout", 15*time.Minute, "watchdog bound on ONE VM's reconcile pass (0 disables); keep above the 10m image-download timeout") | 59 | vmTimeout := flag.Duration("vm-timeout", 15*time.Minute, "watchdog bound on ONE VM's reconcile pass (0 disables); keep above the 10m image-download timeout") |
| @@ -83,6 +86,7 @@ func main() { | |||
| 83 | StateDir: *stateDir, | 86 | StateDir: *stateDir, |
| 84 | CHBin: *chBin, | 87 | CHBin: *chBin, |
| 85 | Firmware: *firmware, | 88 | Firmware: *firmware, |
| 89 | BootstrapURL: *bootstrapURL, | ||
| 86 | TombstoneGrace: *tombstoneGrace, | 90 | TombstoneGrace: *tombstoneGrace, |
| 87 | VanishGrace: *vanishGrace, | 91 | VanishGrace: *vanishGrace, |
| 88 | VMTimeout: *vmTimeout, | 92 | VMTimeout: *vmTimeout, |
| @@ -178,6 +182,16 @@ func runAgent(st *state.Store, cfg agentConfig) { | |||
| 178 | os.Exit(1) | 182 | os.Exit(1) |
| 179 | } | 183 | } |
| 180 | 184 | ||
| 185 | // Bootstrap cloud-hypervisor and its UEFI firmware before anything tries | ||
| 186 | // to launch a VM: a bare host that just joined has neither, and the agent | ||
| 187 | // is useless without at least the hypervisor binary. BootstrapDest maps | ||
| 188 | // the --ch-bin value (usually a bare $PATH name) to a real install path. | ||
| 189 | bs := &bootstrap.Bootstrapper{CHPath: cloudhv.BootstrapDest(cfg.CHBin), FirmwarePath: cfg.Firmware, ManifestURL: cfg.BootstrapURL} | ||
| 190 | if err := bs.Ensure(ctx); err != nil { | ||
| 191 | slog.Error("bootstrap runtime", "err", err) | ||
| 192 | os.Exit(1) | ||
| 193 | } | ||
| 194 | |||
| 181 | prov := cloudhv.New(st, cfg.CHBin, cfg.Firmware, realRunner) | 195 | prov := cloudhv.New(st, cfg.CHBin, cfg.Firmware, realRunner) |
| 182 | 196 | ||
| 183 | // Serial console pumps: one per running VM, started at Boot (cloudhv hook) | 197 | // Serial console pumps: one per running VM, started at Boot (cloudhv hook) |
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -7,17 +7,11 @@ your laptop. `192.0.2.10` is the server below. Substitute yours. | |||
| 7 | 7 | ||
| 8 | ## What you need | 8 | ## What you need |
| 9 | 9 | ||
| 10 | Every VM host needs KVM (`ls -l /dev/kvm`), `qemu-img` (Debian/Ubuntu: | 10 | Every VM host needs KVM (`ls -l /dev/kvm`) and `qemu-img` (Debian/Ubuntu: |
| 11 | `qemu-utils`, Fedora: `qemu-img`), cloud-hypervisor, and the guest firmware: | 11 | `qemu-utils`, Fedora: `qemu-img`). The agent fetches cloud-hypervisor and the |
| 12 | 12 | guest firmware itself on first start, sha-verified against the release. To | |
| 13 | ```sh | 13 | manage them by hand instead, disable it in `/etc/default/eitri-agent`: |
| 14 | sudo install -m 0755 cloud-hypervisor /usr/local/bin/cloud-hypervisor | 14 | `EITRI_AGENT_FLAGS="--bootstrap-url="`. |
| 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 | 15 | ||
| 22 | Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle | 16 | Tarballs live at <https://eitri.sh/dl/latest/>. The host bundle |
| 23 | (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and | 17 | (`eitri_<version>_linux_amd64.tar.gz`) has `eitri-server`, `eitri-agent`, and |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -57,6 +57,7 @@ | |||
| 57 | "plane": "binaries", | 57 | "plane": "binaries", |
| 58 | "synopsis": "eitri-agent: BYO-hardware agent.", | 58 | "synopsis": "eitri-agent: BYO-hardware agent.", |
| 59 | "imports": [ | 59 | "imports": [ |
| 60 | "internal/agent/bootstrap", | ||
| 60 | "internal/agent/cloudhv", | 61 | "internal/agent/cloudhv", |
| 61 | "internal/agent/enrollclient", | 62 | "internal/agent/enrollclient", |
| 62 | "internal/agent/imagecache", | 63 | "internal/agent/imagecache", |
| @@ -140,6 +141,14 @@ | |||
| 140 | ] | 141 | ] |
| 141 | }, | 142 | }, |
| 142 | { | 143 | { |
| 144 | "importPath": "internal/agent/bootstrap", | ||
| 145 | "plane": "data", | ||
| 146 | "synopsis": "Package bootstrap installs the agent's runtime — the cloud-hypervisor binary and its UEFI guest firmware (CLOUDHV.fd) — the first time it is missing on a host, by fetching sha-pinned artifacts from the eitri.sh release manifest (internal/relmanifest).", | ||
| 147 | "imports": [ | ||
| 148 | "internal/relmanifest" | ||
| 149 | ] | ||
| 150 | }, | ||
| 151 | { | ||
| 143 | "importPath": "internal/agent/cloudhv", | 152 | "importPath": "internal/agent/cloudhv", |
| 144 | "plane": "data", | 153 | "plane": "data", |
| 145 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", | 154 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", |
| @@ -309,6 +318,12 @@ | |||
| 309 | "imports": [] | 318 | "imports": [] |
| 310 | }, | 319 | }, |
| 311 | { | 320 | { |
| 321 | "importPath": "internal/relmanifest", | ||
| 322 | "plane": "wire", | ||
| 323 | "synopsis": "Package relmanifest is the eitri.sh release-manifest wire contract, shared by its producer (the site generator), the server (agent-upgrade offers), and the agent (runtime bootstrap).", | ||
| 324 | "imports": [] | ||
| 325 | }, | ||
| 326 | { | ||
| 312 | "importPath": "internal/server/api", | 327 | "importPath": "internal/server/api", |
| 313 | "plane": "control", | 328 | "plane": "control", |
| 314 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", | 329 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", |
| @@ -384,7 +399,9 @@ | |||
| 384 | "importPath": "internal/server/release", | 399 | "importPath": "internal/server/release", |
| 385 | "plane": "control", | 400 | "plane": "control", |
| 386 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.", | 401 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.", |
| 387 | "imports": [] | 402 | "imports": [ |
| 403 | "internal/relmanifest" | ||
| 404 | ] | ||
| 388 | }, | 405 | }, |
| 389 | { | 406 | { |
| 390 | "importPath": "internal/server/sshca", | 407 | "importPath": "internal/server/sshca", |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ | |||
| 6 | "plane": "binaries", | 6 | "plane": "binaries", |
| 7 | "synopsis": "eitri-agent: BYO-hardware agent.", | 7 | "synopsis": "eitri-agent: BYO-hardware agent.", |
| 8 | "imports": [ | 8 | "imports": [ |
| 9 | "internal/agent/bootstrap", | ||
| 9 | "internal/agent/cloudhv", | 10 | "internal/agent/cloudhv", |
| 10 | "internal/agent/enrollclient", | 11 | "internal/agent/enrollclient", |
| 11 | "internal/agent/imagecache", | 12 | "internal/agent/imagecache", |
| @@ -89,6 +90,14 @@ | |||
| 89 | ] | 90 | ] |
| 90 | }, | 91 | }, |
| 91 | { | 92 | { |
| 93 | "importPath": "internal/agent/bootstrap", | ||
| 94 | "plane": "data", | ||
| 95 | "synopsis": "Package bootstrap installs the agent's runtime — the cloud-hypervisor binary and its UEFI guest firmware (CLOUDHV.fd) — the first time it is missing on a host, by fetching sha-pinned artifacts from the eitri.sh release manifest (internal/relmanifest).", | ||
| 96 | "imports": [ | ||
| 97 | "internal/relmanifest" | ||
| 98 | ] | ||
| 99 | }, | ||
| 100 | { | ||
| 92 | "importPath": "internal/agent/cloudhv", | 101 | "importPath": "internal/agent/cloudhv", |
| 93 | "plane": "data", | 102 | "plane": "data", |
| 94 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", | 103 | "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.", |
| @@ -258,6 +267,12 @@ | |||
| 258 | "imports": [] | 267 | "imports": [] |
| 259 | }, | 268 | }, |
| 260 | { | 269 | { |
| 270 | "importPath": "internal/relmanifest", | ||
| 271 | "plane": "wire", | ||
| 272 | "synopsis": "Package relmanifest is the eitri.sh release-manifest wire contract, shared by its producer (the site generator), the server (agent-upgrade offers), and the agent (runtime bootstrap).", | ||
| 273 | "imports": [] | ||
| 274 | }, | ||
| 275 | { | ||
| 261 | "importPath": "internal/server/api", | 276 | "importPath": "internal/server/api", |
| 262 | "plane": "control", | 277 | "plane": "control", |
| 263 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", | 278 | "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.", |
| @@ -333,7 +348,9 @@ | |||
| 333 | "importPath": "internal/server/release", | 348 | "importPath": "internal/server/release", |
| 334 | "plane": "control", | 349 | "plane": "control", |
| 335 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.", | 350 | "synopsis": "Package release discovers the latest eitri release from a manifest URL (eitri.sh) and orders versions.", |
| 336 | "imports": [] | 351 | "imports": [ |
| 352 | "internal/relmanifest" | ||
| 353 | ] | ||
| 337 | }, | 354 | }, |
| 338 | { | 355 | { |
| 339 | "importPath": "internal/server/sshca", | 356 | "importPath": "internal/server/sshca", |
internal/agent/bootstrap/bootstrap.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,220 @@ | |||
| 1 | // Package bootstrap installs the agent's runtime — the cloud-hypervisor | ||
| 2 | // binary and its UEFI guest firmware (CLOUDHV.fd) — the first time it is | ||
| 3 | // missing on a host, by fetching sha-pinned artifacts from the eitri.sh | ||
| 4 | // release manifest (internal/relmanifest). It runs once at agent startup, | ||
| 5 | // before the first reconcile. | ||
| 6 | // | ||
| 7 | // Existing fleets and airgapped hosts never touch the network: if both files | ||
| 8 | // are already present, Ensure returns immediately with zero HTTP requests. | ||
| 9 | // An empty manifest URL disables bootstrap entirely — the operator manages | ||
| 10 | // the runtime by hand. | ||
| 11 | // | ||
| 12 | // Ensure is idempotent per file: a file that already exists is never | ||
| 13 | // touched. There is no version comparison — upgrading an already-installed | ||
| 14 | // runtime is a separate concern from bootstrapping a missing one. | ||
| 15 | package bootstrap | ||
| 16 | |||
| 17 | import ( | ||
| 18 | "context" | ||
| 19 | "crypto/sha256" | ||
| 20 | "encoding/hex" | ||
| 21 | "encoding/json" | ||
| 22 | "fmt" | ||
| 23 | "io" | ||
| 24 | "log/slog" | ||
| 25 | "net/http" | ||
| 26 | "os" | ||
| 27 | "path/filepath" | ||
| 28 | "runtime" | ||
| 29 | "time" | ||
| 30 | |||
| 31 | "github.com/a73x/eitri/internal/relmanifest" | ||
| 32 | ) | ||
| 33 | |||
| 34 | // manifestMaxBytes bounds the manifest fetch: it is a small, hand-authored | ||
| 35 | // JSON document, so anything vastly larger indicates a misconfigured or | ||
| 36 | // hostile URL rather than a legitimate manifest. | ||
| 37 | const manifestMaxBytes = 1 << 20 | ||
| 38 | |||
| 39 | // Bootstrapper installs cloud-hypervisor and CLOUDHV.fd if either is missing | ||
| 40 | // on disk. The zero value works in production against a configured | ||
| 41 | // ManifestURL; tests inject the seams. | ||
| 42 | type Bootstrapper struct { | ||
| 43 | // HTTP is the manifest+artifact download client (nil ⇒ a 5-minute-timeout | ||
| 44 | // default, mirroring selfupdate's seam style). | ||
| 45 | HTTP *http.Client | ||
| 46 | // Log receives progress and warning lines (nil ⇒ slog.Default()). | ||
| 47 | Log *slog.Logger | ||
| 48 | // CHPath and FirmwarePath are the destination paths this agent was | ||
| 49 | // started with (the same values as its --ch-bin/--firmware flags). | ||
| 50 | CHPath, FirmwarePath string | ||
| 51 | // ManifestURL is the eitri.sh release manifest to fetch from. "" disables | ||
| 52 | // bootstrap entirely: the operator manages the runtime by hand. | ||
| 53 | ManifestURL string | ||
| 54 | } | ||
| 55 | |||
| 56 | func (b *Bootstrapper) httpClient() *http.Client { | ||
| 57 | if b.HTTP != nil { | ||
| 58 | return b.HTTP | ||
| 59 | } | ||
| 60 | return &http.Client{Timeout: 5 * time.Minute} | ||
| 61 | } | ||
| 62 | |||
| 63 | func (b *Bootstrapper) log() *slog.Logger { | ||
| 64 | if b.Log != nil { | ||
| 65 | return b.Log | ||
| 66 | } | ||
| 67 | return slog.Default() | ||
| 68 | } | ||
| 69 | |||
| 70 | // Ensure installs whichever of cloud-hypervisor / CLOUDHV.fd is missing at | ||
| 71 | // CHPath / FirmwarePath, fetching the manifest from ManifestURL. It is meant | ||
| 72 | // to run once, before the first reconcile. | ||
| 73 | func (b *Bootstrapper) Ensure(ctx context.Context) error { | ||
| 74 | chMissing := !fileExists(b.CHPath) | ||
| 75 | fwMissing := !fileExists(b.FirmwarePath) | ||
| 76 | if !chMissing && !fwMissing { | ||
| 77 | return nil | ||
| 78 | } | ||
| 79 | |||
| 80 | if b.ManifestURL == "" { | ||
| 81 | b.log().Info("bootstrap: runtime file(s) missing and no manifest URL configured — operator manages cloud-hypervisor/firmware by hand", | ||
| 82 | "ch_missing", chMissing, "firmware_missing", fwMissing) | ||
| 83 | return nil | ||
| 84 | } | ||
| 85 | |||
| 86 | man, err := b.fetchManifest(ctx) | ||
| 87 | if err != nil { | ||
| 88 | return fmt.Errorf("bootstrap: fetch manifest: %w", err) | ||
| 89 | } | ||
| 90 | platform := runtime.GOOS + "/" + runtime.GOARCH | ||
| 91 | |||
| 92 | if chMissing { | ||
| 93 | art, ok := man.Artifacts["cloud-hypervisor"][platform] | ||
| 94 | if !ok { | ||
| 95 | return fmt.Errorf("bootstrap: manifest has no cloud-hypervisor artifact for platform %s; the agent cannot run without it", platform) | ||
| 96 | } | ||
| 97 | if err := b.install(ctx, art, b.CHPath, 0o755); err != nil { | ||
| 98 | return fmt.Errorf("bootstrap: install cloud-hypervisor: %w", err) | ||
| 99 | } | ||
| 100 | b.log().Info("bootstrap: installed cloud-hypervisor", "path", b.CHPath, "version", man.Version) | ||
| 101 | } | ||
| 102 | |||
| 103 | if fwMissing { | ||
| 104 | art, ok := man.Artifacts["firmware"][platform] | ||
| 105 | if !ok { | ||
| 106 | b.log().Warn("bootstrap: manifest has no firmware artifact for this platform; VM creates will fail until it is installed by hand", | ||
| 107 | "platform", platform, "path", b.FirmwarePath) | ||
| 108 | } else if err := b.install(ctx, art, b.FirmwarePath, 0o644); err != nil { | ||
| 109 | return fmt.Errorf("bootstrap: install firmware: %w", err) | ||
| 110 | } else { | ||
| 111 | b.log().Info("bootstrap: installed firmware", "path", b.FirmwarePath, "version", man.Version) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | return nil | ||
| 116 | } | ||
| 117 | |||
| 118 | // fetchManifest GETs and decodes the release manifest. | ||
| 119 | func (b *Bootstrapper) fetchManifest(ctx context.Context) (relmanifest.Manifest, error) { | ||
| 120 | var man relmanifest.Manifest | ||
| 121 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.ManifestURL, nil) | ||
| 122 | if err != nil { | ||
| 123 | return man, err | ||
| 124 | } | ||
| 125 | resp, err := b.httpClient().Do(req) | ||
| 126 | if err != nil { | ||
| 127 | return man, fmt.Errorf("GET %s: %w", b.ManifestURL, err) | ||
| 128 | } | ||
| 129 | defer resp.Body.Close() | ||
| 130 | if resp.StatusCode/100 != 2 { | ||
| 131 | return man, fmt.Errorf("GET %s: HTTP %d", b.ManifestURL, resp.StatusCode) | ||
| 132 | } | ||
| 133 | if err := json.NewDecoder(io.LimitReader(resp.Body, manifestMaxBytes)).Decode(&man); err != nil { | ||
| 134 | return man, fmt.Errorf("decode manifest from %s: %w", b.ManifestURL, err) | ||
| 135 | } | ||
| 136 | return man, nil | ||
| 137 | } | ||
| 138 | |||
| 139 | // install downloads art to dest, streaming its sha256 and verifying it | ||
| 140 | // against art.SHA256 before the file is ever visible at dest. It mirrors | ||
| 141 | // selfupdate's durable-install idiom: a temp file beside the destination (so | ||
| 142 | // the final rename is atomic on the same filesystem), fsync before close, | ||
| 143 | // then an atomic rename plus a best-effort parent-dir fsync. | ||
| 144 | func (b *Bootstrapper) install(ctx context.Context, art relmanifest.Artifact, dest string, perm os.FileMode) error { | ||
| 145 | dir := filepath.Dir(dest) | ||
| 146 | if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| 147 | return fmt.Errorf("create parent dir %s: %w", dir, err) | ||
| 148 | } | ||
| 149 | |||
| 150 | // Sweep temps a crashed prior run left behind; they never block a fresh | ||
| 151 | // install (dest-existence gates Ensure) but would accumulate. | ||
| 152 | if stale, err := filepath.Glob(filepath.Join(dir, ".eitri-bootstrap-*")); err == nil { | ||
| 153 | for _, s := range stale { | ||
| 154 | os.Remove(s) | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | tmp, err := os.CreateTemp(dir, ".eitri-bootstrap-*") | ||
| 159 | if err != nil { | ||
| 160 | return fmt.Errorf("create temp beside %s: %w", dest, err) | ||
| 161 | } | ||
| 162 | defer os.Remove(tmp.Name()) // no-op after a successful rename | ||
| 163 | |||
| 164 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, art.URL, nil) | ||
| 165 | if err != nil { | ||
| 166 | tmp.Close() | ||
| 167 | return err | ||
| 168 | } | ||
| 169 | resp, err := b.httpClient().Do(req) | ||
| 170 | if err != nil { | ||
| 171 | tmp.Close() | ||
| 172 | return fmt.Errorf("download %s: %w", art.URL, err) | ||
| 173 | } | ||
| 174 | defer resp.Body.Close() | ||
| 175 | if resp.StatusCode/100 != 2 { | ||
| 176 | tmp.Close() | ||
| 177 | return fmt.Errorf("download %s: HTTP %d", art.URL, resp.StatusCode) | ||
| 178 | } | ||
| 179 | |||
| 180 | h := sha256.New() | ||
| 181 | if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { | ||
| 182 | tmp.Close() | ||
| 183 | return fmt.Errorf("download %s: %w", art.URL, err) | ||
| 184 | } | ||
| 185 | // Durability: fsync the downloaded bytes before Close, same rationale as | ||
| 186 | // selfupdate — a torn install can't heal itself on the next Ensure (the | ||
| 187 | // file would already "exist" and be skipped). | ||
| 188 | if err := tmp.Sync(); err != nil { | ||
| 189 | tmp.Close() | ||
| 190 | return fmt.Errorf("sync downloaded temp: %w", err) | ||
| 191 | } | ||
| 192 | if err := tmp.Close(); err != nil { | ||
| 193 | return err | ||
| 194 | } | ||
| 195 | if got := hex.EncodeToString(h.Sum(nil)); got != art.SHA256 { | ||
| 196 | return fmt.Errorf("sha256 mismatch for %s: got %s want %s", art.URL, got, art.SHA256) | ||
| 197 | } | ||
| 198 | if err := os.Chmod(tmp.Name(), perm); err != nil { | ||
| 199 | return err | ||
| 200 | } | ||
| 201 | if err := os.Rename(tmp.Name(), dest); err != nil { | ||
| 202 | return fmt.Errorf("install %s: %w", dest, err) | ||
| 203 | } | ||
| 204 | // Durability: fsync the directory entry itself — see selfupdate's note on | ||
| 205 | // why a rename alone is not durable on every filesystem. Best-effort: the | ||
| 206 | // install already happened either way. | ||
| 207 | if df, derr := os.Open(dir); derr == nil { | ||
| 208 | _ = df.Sync() | ||
| 209 | _ = df.Close() | ||
| 210 | } | ||
| 211 | return nil | ||
| 212 | } | ||
| 213 | |||
| 214 | // fileExists reports whether path names an existing file (any stat error, | ||
| 215 | // including permission errors, is treated as "missing" — Ensure's job is to | ||
| 216 | // put a working file there, not to diagnose why one isn't visible). | ||
| 217 | func fileExists(path string) bool { | ||
| 218 | _, err := os.Stat(path) | ||
| 219 | return err == nil | ||
| 220 | } | ||
internal/agent/bootstrap/bootstrap_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,229 @@ | |||
| 1 | package bootstrap | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "context" | ||
| 6 | "crypto/sha256" | ||
| 7 | "encoding/hex" | ||
| 8 | "encoding/json" | ||
| 9 | "log/slog" | ||
| 10 | "net/http" | ||
| 11 | "net/http/httptest" | ||
| 12 | "os" | ||
| 13 | "path/filepath" | ||
| 14 | "runtime" | ||
| 15 | "strings" | ||
| 16 | "testing" | ||
| 17 | |||
| 18 | "github.com/a73x/eitri/internal/relmanifest" | ||
| 19 | ) | ||
| 20 | |||
| 21 | var platform = runtime.GOOS + "/" + runtime.GOARCH | ||
| 22 | |||
| 23 | // fixture describes a manifest server's content: which artifact keys are | ||
| 24 | // present for the test's platform, their bodies, and (optionally) a SHA256 | ||
| 25 | // override to force a mismatch. | ||
| 26 | type fixture struct { | ||
| 27 | chBody, fwBody []byte | ||
| 28 | chSHA, fwSHA string // "" ⇒ compute the correct sha of the body | ||
| 29 | includeCH, includeFW bool | ||
| 30 | } | ||
| 31 | |||
| 32 | // newServer stands up an httptest server exposing /manifest.json, /ch, and | ||
| 33 | // /fw, and returns the manifest URL. Also returns the running request | ||
| 34 | // counter so callers can assert on how many requests were made. | ||
| 35 | func newServer(t *testing.T, f fixture) (manifestURL string, requests *int) { | ||
| 36 | t.Helper() | ||
| 37 | mux := http.NewServeMux() | ||
| 38 | srv := httptest.NewServer(mux) | ||
| 39 | t.Cleanup(srv.Close) | ||
| 40 | |||
| 41 | count := 0 | ||
| 42 | requests = &count | ||
| 43 | |||
| 44 | mux.HandleFunc("/ch", func(w http.ResponseWriter, r *http.Request) { | ||
| 45 | *requests++ | ||
| 46 | w.Write(f.chBody) | ||
| 47 | }) | ||
| 48 | mux.HandleFunc("/fw", func(w http.ResponseWriter, r *http.Request) { | ||
| 49 | *requests++ | ||
| 50 | w.Write(f.fwBody) | ||
| 51 | }) | ||
| 52 | |||
| 53 | artifacts := map[string]map[string]relmanifest.Artifact{} | ||
| 54 | if f.includeCH { | ||
| 55 | sha := f.chSHA | ||
| 56 | if sha == "" { | ||
| 57 | sum := sha256.Sum256(f.chBody) | ||
| 58 | sha = hex.EncodeToString(sum[:]) | ||
| 59 | } | ||
| 60 | artifacts["cloud-hypervisor"] = map[string]relmanifest.Artifact{platform: {URL: srv.URL + "/ch", SHA256: sha}} | ||
| 61 | } | ||
| 62 | if f.includeFW { | ||
| 63 | sha := f.fwSHA | ||
| 64 | if sha == "" { | ||
| 65 | sum := sha256.Sum256(f.fwBody) | ||
| 66 | sha = hex.EncodeToString(sum[:]) | ||
| 67 | } | ||
| 68 | artifacts["firmware"] = map[string]relmanifest.Artifact{platform: {URL: srv.URL + "/fw", SHA256: sha}} | ||
| 69 | } | ||
| 70 | manifest := relmanifest.Manifest{Version: "v1", Artifacts: artifacts} | ||
| 71 | |||
| 72 | mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { | ||
| 73 | *requests++ | ||
| 74 | _ = json.NewEncoder(w).Encode(manifest) | ||
| 75 | }) | ||
| 76 | return srv.URL + "/manifest.json", requests | ||
| 77 | } | ||
| 78 | |||
| 79 | func TestEnsureNoopWhenBothFilesExist(t *testing.T) { | ||
| 80 | dir := t.TempDir() | ||
| 81 | chPath := filepath.Join(dir, "cloud-hypervisor") | ||
| 82 | fwPath := filepath.Join(dir, "CLOUDHV.fd") | ||
| 83 | if err := os.WriteFile(chPath, []byte("ch"), 0o755); err != nil { | ||
| 84 | t.Fatal(err) | ||
| 85 | } | ||
| 86 | if err := os.WriteFile(fwPath, []byte("fw"), 0o644); err != nil { | ||
| 87 | t.Fatal(err) | ||
| 88 | } | ||
| 89 | |||
| 90 | requests := 0 | ||
| 91 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 92 | requests++ | ||
| 93 | w.WriteHeader(http.StatusInternalServerError) | ||
| 94 | })) | ||
| 95 | t.Cleanup(srv.Close) | ||
| 96 | |||
| 97 | b := &Bootstrapper{CHPath: chPath, FirmwarePath: fwPath, ManifestURL: srv.URL} | ||
| 98 | if err := b.Ensure(context.Background()); err != nil { | ||
| 99 | t.Fatalf("Ensure: %v", err) | ||
| 100 | } | ||
| 101 | if requests != 0 { | ||
| 102 | t.Fatalf("want zero HTTP requests when both files exist, got %d", requests) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | func TestEnsureInstallsBothWhenMissing(t *testing.T) { | ||
| 107 | dir := t.TempDir() | ||
| 108 | // Nested, not-yet-created parent dirs: Ensure must MkdirAll them. | ||
| 109 | chPath := filepath.Join(dir, "bin", "cloud-hypervisor") | ||
| 110 | fwPath := filepath.Join(dir, "share", "CLOUDHV.fd") | ||
| 111 | |||
| 112 | chBody := []byte("ch-binary-contents") | ||
| 113 | fwBody := []byte("firmware-contents") | ||
| 114 | manifestURL, requests := newServer(t, fixture{chBody: chBody, fwBody: fwBody, includeCH: true, includeFW: true}) | ||
| 115 | |||
| 116 | b := &Bootstrapper{CHPath: chPath, FirmwarePath: fwPath, ManifestURL: manifestURL} | ||
| 117 | if err := b.Ensure(context.Background()); err != nil { | ||
| 118 | t.Fatalf("Ensure: %v", err) | ||
| 119 | } | ||
| 120 | if *requests == 0 { | ||
| 121 | t.Fatal("want at least one HTTP request") | ||
| 122 | } | ||
| 123 | |||
| 124 | gotCH, err := os.ReadFile(chPath) | ||
| 125 | if err != nil || string(gotCH) != string(chBody) { | ||
| 126 | t.Fatalf("cloud-hypervisor contents = %q, %v; want %q", gotCH, err, chBody) | ||
| 127 | } | ||
| 128 | if fi, _ := os.Stat(chPath); fi.Mode().Perm() != 0o755 { | ||
| 129 | t.Fatalf("cloud-hypervisor mode = %v, want 0755", fi.Mode().Perm()) | ||
| 130 | } | ||
| 131 | |||
| 132 | gotFW, err := os.ReadFile(fwPath) | ||
| 133 | if err != nil || string(gotFW) != string(fwBody) { | ||
| 134 | t.Fatalf("firmware contents = %q, %v; want %q", gotFW, err, fwBody) | ||
| 135 | } | ||
| 136 | if fi, _ := os.Stat(fwPath); fi.Mode().Perm() != 0o644 { | ||
| 137 | t.Fatalf("firmware mode = %v, want 0644", fi.Mode().Perm()) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | func TestEnsureShaMismatchOnCloudHypervisor(t *testing.T) { | ||
| 142 | dir := t.TempDir() | ||
| 143 | chPath := filepath.Join(dir, "cloud-hypervisor") | ||
| 144 | fwPath := filepath.Join(dir, "CLOUDHV.fd") | ||
| 145 | // Firmware already present so only the cloud-hypervisor path is exercised. | ||
| 146 | if err := os.WriteFile(fwPath, []byte("fw"), 0o644); err != nil { | ||
| 147 | t.Fatal(err) | ||
| 148 | } | ||
| 149 | |||
| 150 | manifestURL, _ := newServer(t, fixture{chBody: []byte("ch-binary"), chSHA: "deadbeef", includeCH: true}) | ||
| 151 | |||
| 152 | b := &Bootstrapper{CHPath: chPath, FirmwarePath: fwPath, ManifestURL: manifestURL} | ||
| 153 | err := b.Ensure(context.Background()) | ||
| 154 | if err == nil { | ||
| 155 | t.Fatal("want sha mismatch error") | ||
| 156 | } | ||
| 157 | if _, statErr := os.Stat(chPath); !os.IsNotExist(statErr) { | ||
| 158 | t.Fatal("cloud-hypervisor destination must be absent after sha mismatch") | ||
| 159 | } | ||
| 160 | entries, _ := os.ReadDir(dir) | ||
| 161 | for _, e := range entries { | ||
| 162 | if e.Name() != "CLOUDHV.fd" { | ||
| 163 | t.Fatalf("temp file leaked in %s: %v", dir, e.Name()) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | func TestEnsureSkipsWhenManifestURLEmpty(t *testing.T) { | ||
| 169 | dir := t.TempDir() | ||
| 170 | chPath := filepath.Join(dir, "cloud-hypervisor") | ||
| 171 | fwPath := filepath.Join(dir, "CLOUDHV.fd") | ||
| 172 | |||
| 173 | b := &Bootstrapper{CHPath: chPath, FirmwarePath: fwPath, ManifestURL: ""} | ||
| 174 | if err := b.Ensure(context.Background()); err != nil { | ||
| 175 | t.Fatalf("Ensure: %v", err) | ||
| 176 | } | ||
| 177 | if _, err := os.Stat(chPath); !os.IsNotExist(err) { | ||
| 178 | t.Fatal("cloud-hypervisor must not be installed when ManifestURL is empty") | ||
| 179 | } | ||
| 180 | if _, err := os.Stat(fwPath); !os.IsNotExist(err) { | ||
| 181 | t.Fatal("firmware must not be installed when ManifestURL is empty") | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | func TestEnsureWarnsWhenFirmwareEntryAbsent(t *testing.T) { | ||
| 186 | dir := t.TempDir() | ||
| 187 | chPath := filepath.Join(dir, "cloud-hypervisor") | ||
| 188 | fwPath := filepath.Join(dir, "CLOUDHV.fd") // missing; manifest has no entry (arm64-style) | ||
| 189 | |||
| 190 | chBody := []byte("ch-binary") | ||
| 191 | manifestURL, _ := newServer(t, fixture{chBody: chBody, includeCH: true, includeFW: false}) | ||
| 192 | |||
| 193 | var logs bytes.Buffer | ||
| 194 | logger := slog.New(slog.NewTextHandler(&logs, nil)) | ||
| 195 | |||
| 196 | b := &Bootstrapper{CHPath: chPath, FirmwarePath: fwPath, ManifestURL: manifestURL, Log: logger} | ||
| 197 | if err := b.Ensure(context.Background()); err != nil { | ||
| 198 | t.Fatalf("Ensure: %v", err) | ||
| 199 | } | ||
| 200 | if _, err := os.Stat(chPath); err != nil { | ||
| 201 | t.Fatalf("cloud-hypervisor must be installed: %v", err) | ||
| 202 | } | ||
| 203 | if _, err := os.Stat(fwPath); !os.IsNotExist(err) { | ||
| 204 | t.Fatal("firmware must remain absent when the manifest has no entry for this platform") | ||
| 205 | } | ||
| 206 | if !strings.Contains(logs.String(), "level=WARN") { | ||
| 207 | t.Fatalf("want a warning logged for the missing firmware entry, got: %s", logs.String()) | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | func TestEnsureErrorsWhenCloudHypervisorEntryAbsent(t *testing.T) { | ||
| 212 | dir := t.TempDir() | ||
| 213 | chPath := filepath.Join(dir, "cloud-hypervisor") // missing; manifest has no entry | ||
| 214 | fwPath := filepath.Join(dir, "CLOUDHV.fd") | ||
| 215 | if err := os.WriteFile(fwPath, []byte("fw"), 0o644); err != nil { | ||
| 216 | t.Fatal(err) | ||
| 217 | } | ||
| 218 | |||
| 219 | manifestURL, _ := newServer(t, fixture{includeCH: false, includeFW: false}) | ||
| 220 | |||
| 221 | b := &Bootstrapper{CHPath: chPath, FirmwarePath: fwPath, ManifestURL: manifestURL} | ||
| 222 | err := b.Ensure(context.Background()) | ||
| 223 | if err == nil { | ||
| 224 | t.Fatal("want an error when cloud-hypervisor is missing and has no manifest entry") | ||
| 225 | } | ||
| 226 | if !strings.Contains(err.Error(), "cloud-hypervisor") { | ||
| 227 | t.Fatalf("error should name cloud-hypervisor, got: %v", err) | ||
| 228 | } | ||
| 229 | } | ||
internal/agent/cloudhv/cloudhv.go
| Old | New | ||
|---|---|---|---|
| @@ -49,6 +49,24 @@ func New(st *state.Store, chBin, firmware string, run agentexec.Runner) *Provisi | |||
| 49 | return &Provisioner{st: st, chBin: chBin, firmware: firmware, run: run} | 49 | return &Provisioner{st: st, chBin: chBin, firmware: firmware, run: run} |
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | // BootstrapDest maps a --ch-bin value to the filesystem path bootstrap may | ||
| 53 | // install the binary at. The two are different vocabularies: --ch-bin is | ||
| 54 | // usually a bare command name resolved on $PATH at launch, which names no | ||
| 55 | // install destination — writing to it literally would drop the binary in the | ||
| 56 | // agent's working directory and every launch would still miss it. A bare name | ||
| 57 | // resolves via $PATH when already installed (so bootstrap sees it and | ||
| 58 | // no-ops), else lands in /usr/local/bin, which the systemd unit's default | ||
| 59 | // $PATH includes. An explicit path is its own destination. | ||
| 60 | func BootstrapDest(chBin string) string { | ||
| 61 | if strings.ContainsRune(chBin, os.PathSeparator) { | ||
| 62 | return chBin | ||
| 63 | } | ||
| 64 | if p, err := exec.LookPath(chBin); err == nil { | ||
| 65 | return p | ||
| 66 | } | ||
| 67 | return filepath.Join("/usr/local/bin", chBin) | ||
| 68 | } | ||
| 69 | |||
| 52 | // buildArgs returns the cloud-hypervisor command-line arguments for spec. | 70 | // buildArgs returns the cloud-hypervisor command-line arguments for spec. |
| 53 | // The result is deterministic given the same spec so it can be unit-tested | 71 | // The result is deterministic given the same spec so it can be unit-tested |
| 54 | // without spawning a process. | 72 | // without spawning a process. |
internal/agent/cloudhv/cloudhv_test.go
| Old | New | ||
|---|---|---|---|
| @@ -352,3 +352,33 @@ func TestDiskGuardErrorsArePermanent(t *testing.T) { | |||
| 352 | require.Error(t, err) | 352 | require.Error(t, err) |
| 353 | assert.False(t, isPermanent(err), "stat failure may be transient (NFS blip, cache re-fetch)") | 353 | assert.False(t, isPermanent(err), "stat failure may be transient (NFS blip, cache re-fetch)") |
| 354 | } | 354 | } |
| 355 | |||
| 356 | // TestBootstrapDest pins the --ch-bin → install-destination mapping: a fresh | ||
| 357 | // host's bare name lands on the systemd default $PATH (not the agent's cwd, | ||
| 358 | // where launch-time lookup would never find it); an already-installed bare | ||
| 359 | // name resolves to itself so Ensure no-ops; an explicit path is its own | ||
| 360 | // destination. | ||
| 361 | func TestBootstrapDest(t *testing.T) { | ||
| 362 | t.Run("explicit path is its own destination", func(t *testing.T) { | ||
| 363 | if got := BootstrapDest("/opt/ch/cloud-hypervisor"); got != "/opt/ch/cloud-hypervisor" { | ||
| 364 | t.Errorf("got %q", got) | ||
| 365 | } | ||
| 366 | }) | ||
| 367 | t.Run("bare name not on PATH installs to /usr/local/bin", func(t *testing.T) { | ||
| 368 | t.Setenv("PATH", t.TempDir()) | ||
| 369 | if got := BootstrapDest("cloud-hypervisor"); got != "/usr/local/bin/cloud-hypervisor" { | ||
| 370 | t.Errorf("got %q", got) | ||
| 371 | } | ||
| 372 | }) | ||
| 373 | t.Run("bare name on PATH resolves to the installed binary", func(t *testing.T) { | ||
| 374 | dir := t.TempDir() | ||
| 375 | bin := filepath.Join(dir, "cloud-hypervisor") | ||
| 376 | if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { | ||
| 377 | t.Fatal(err) | ||
| 378 | } | ||
| 379 | t.Setenv("PATH", dir) | ||
| 380 | if got := BootstrapDest("cloud-hypervisor"); got != bin { | ||
| 381 | t.Errorf("got %q, want %q", got, bin) | ||
| 382 | } | ||
| 383 | }) | ||
| 384 | } | ||
internal/relmanifest/relmanifest.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,17 @@ | |||
| 1 | // Package relmanifest is the eitri.sh release-manifest wire contract, shared | ||
| 2 | // by its producer (the site generator), the server (agent-upgrade offers), | ||
| 3 | // and the agent (runtime bootstrap). One type, no drift. | ||
| 4 | package relmanifest | ||
| 5 | |||
| 6 | // Artifact is one downloadable file. | ||
| 7 | type Artifact struct { | ||
| 8 | URL string `json:"url"` | ||
| 9 | SHA256 string `json:"sha256"` | ||
| 10 | } | ||
| 11 | |||
| 12 | // Manifest is the eitri.sh release manifest: a version plus per-binary, | ||
| 13 | // per-platform artifacts (keys like "eitri-agent" → "linux/amd64"). | ||
| 14 | type Manifest struct { | ||
| 15 | Version string `json:"version"` | ||
| 16 | Artifacts map[string]map[string]Artifact `json:"artifacts"` | ||
| 17 | } | ||
internal/server/release/release.go
| Old | New | ||
|---|---|---|---|
| @@ -13,20 +13,15 @@ import ( | |||
| 13 | "strings" | 13 | "strings" |
| 14 | "sync" | 14 | "sync" |
| 15 | "time" | 15 | "time" |
| 16 | ) | ||
| 17 | 16 | ||
| 18 | // Artifact is one downloadable binary build. | 17 | "github.com/a73x/eitri/internal/relmanifest" |
| 19 | type Artifact struct { | 18 | ) |
| 20 | URL string `json:"url"` | ||
| 21 | SHA256 string `json:"sha256"` | ||
| 22 | } | ||
| 23 | 19 | ||
| 24 | // Manifest is the eitri.sh release manifest: a version plus per-binary, | 20 | // Artifact and Manifest are the shared wire types; see internal/relmanifest. |
| 25 | // per-platform artifacts (keys like "eitri-agent" → "linux/amd64"). | 21 | type ( |
| 26 | type Manifest struct { | 22 | Artifact = relmanifest.Artifact |
| 27 | Version string `json:"version"` | 23 | Manifest = relmanifest.Manifest |
| 28 | Artifacts map[string]map[string]Artifact `json:"artifacts"` | 24 | ) |
| 29 | } | ||
| 30 | 25 | ||
| 31 | // Client fetches and caches the latest manifest. Construct with New. | 26 | // Client fetches and caches the latest manifest. Construct with New. |
| 32 | type Client struct { | 27 | type Client struct { |
internal/shape/classify.go
| Old | New | ||
|---|---|---|---|
| @@ -35,6 +35,7 @@ func classify(rel string) Plane { | |||
| 35 | strings.HasPrefix(rel, "internal/cloudinit"), | 35 | strings.HasPrefix(rel, "internal/cloudinit"), |
| 36 | strings.HasPrefix(rel, "internal/names"), | 36 | strings.HasPrefix(rel, "internal/names"), |
| 37 | strings.HasPrefix(rel, "internal/random"), | 37 | strings.HasPrefix(rel, "internal/random"), |
| 38 | strings.HasPrefix(rel, "internal/relmanifest"), | ||
| 38 | strings.HasPrefix(rel, "internal/version"): | 39 | strings.HasPrefix(rel, "internal/version"): |
| 39 | return PlaneWire | 40 | return PlaneWire |
| 40 | case strings.HasPrefix(rel, "cmd/"): | 41 | case strings.HasPrefix(rel, "cmd/"): |
internal/site/manifest.go
| Old | New | ||
|---|---|---|---|
| @@ -17,9 +17,19 @@ import ( | |||
| 17 | // eitri-agent_<os>_<arch>. These are what the agent self-updater downloads. | 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]+)$`) | 18 | var barePat = regexp.MustCompile(`^eitri-agent_([a-z0-9]+)_([a-z0-9]+)$`) |
| 19 | 19 | ||
| 20 | // BuildManifest scans distDir for bare eitri-agent binaries and produces the | 20 | // chPat matches the pinned cloud-hypervisor binaries mirrored into dist/: |
| 21 | // release manifest the server polls. Sharing release.Manifest with the | 21 | // cloud-hypervisor_<os>_<arch>. What the agent bootstraps its runtime from. |
| 22 | // consumer is deliberate: the wire contract lives in one type. | 22 | var chPat = regexp.MustCompile(`^cloud-hypervisor_([a-z0-9]+)_([a-z0-9]+)$`) |
| 23 | |||
| 24 | // firmwareName is the guest UEFI firmware mirrored into dist/, if present. | ||
| 25 | // edk2 CLOUDHV is x86-64 only, so it manifests under a single platform key. | ||
| 26 | const firmwareName = "CLOUDHV.fd" | ||
| 27 | |||
| 28 | // BuildManifest scans distDir for bare eitri-agent binaries — required — plus | ||
| 29 | // optional runtime artifacts (a pinned cloud-hypervisor and guest firmware) | ||
| 30 | // and produces the release manifest the server polls and the agent | ||
| 31 | // bootstraps from. Sharing release.Manifest with the consumers is | ||
| 32 | // deliberate: the wire contract lives in one type. | ||
| 23 | func BuildManifest(version, distDir, baseURL string) (release.Manifest, error) { | 33 | func BuildManifest(version, distDir, baseURL string) (release.Manifest, error) { |
| 24 | if version == "" { | 34 | if version == "" { |
| 25 | return release.Manifest{}, fmt.Errorf("version required") | 35 | return release.Manifest{}, fmt.Errorf("version required") |
| @@ -33,8 +43,20 @@ func BuildManifest(version, distDir, baseURL string) (release.Manifest, error) { | |||
| 33 | return release.Manifest{}, err | 43 | return release.Manifest{}, err |
| 34 | } | 44 | } |
| 35 | for _, e := range entries { | 45 | for _, e := range entries { |
| 36 | match := barePat.FindStringSubmatch(e.Name()) | 46 | if e.IsDir() { |
| 37 | if e.IsDir() || match == nil { | 47 | continue |
| 48 | } | ||
| 49 | key, platform := "", "" | ||
| 50 | switch { | ||
| 51 | case barePat.MatchString(e.Name()): | ||
| 52 | match := barePat.FindStringSubmatch(e.Name()) | ||
| 53 | key, platform = "eitri-agent", match[1]+"/"+match[2] | ||
| 54 | case chPat.MatchString(e.Name()): | ||
| 55 | match := chPat.FindStringSubmatch(e.Name()) | ||
| 56 | key, platform = "cloud-hypervisor", match[1]+"/"+match[2] | ||
| 57 | case e.Name() == firmwareName: | ||
| 58 | key, platform = "firmware", "linux/amd64" | ||
| 59 | default: | ||
| 38 | continue | 60 | continue |
| 39 | } | 61 | } |
| 40 | sum, err := fileSHA256(filepath.Join(distDir, e.Name())) | 62 | sum, err := fileSHA256(filepath.Join(distDir, e.Name())) |
| @@ -45,7 +67,10 @@ func BuildManifest(version, distDir, baseURL string) (release.Manifest, error) { | |||
| 45 | if err != nil { | 67 | if err != nil { |
| 46 | return release.Manifest{}, err | 68 | return release.Manifest{}, err |
| 47 | } | 69 | } |
| 48 | m.Artifacts["eitri-agent"][match[1]+"/"+match[2]] = release.Artifact{ | 70 | if m.Artifacts[key] == nil { |
| 71 | m.Artifacts[key] = map[string]release.Artifact{} | ||
| 72 | } | ||
| 73 | m.Artifacts[key][platform] = release.Artifact{ | ||
| 49 | URL: u, | 74 | URL: u, |
| 50 | SHA256: sum, | 75 | SHA256: sum, |
| 51 | } | 76 | } |
internal/site/manifest_test.go
| Old | New | ||
|---|---|---|---|
| @@ -84,3 +84,71 @@ func TestBuildManifestNormalizesBaseURL(t *testing.T) { | |||
| 84 | t.Errorf("trailing-slash base not normalized: %q", got) | 84 | t.Errorf("trailing-slash base not normalized: %q", got) |
| 85 | } | 85 | } |
| 86 | } | 86 | } |
| 87 | |||
| 88 | func TestBuildManifestIncludesRuntimeArtifacts(t *testing.T) { | ||
| 89 | dist := t.TempDir() | ||
| 90 | agent := []byte("fake agent binary") | ||
| 91 | ch := []byte("fake cloud-hypervisor binary") | ||
| 92 | fw := []byte("fake CLOUDHV.fd") | ||
| 93 | files := map[string][]byte{ | ||
| 94 | "eitri-agent_linux_amd64": agent, | ||
| 95 | "eitri-agent_linux_arm64": agent, | ||
| 96 | "cloud-hypervisor_linux_amd64": ch, | ||
| 97 | "cloud-hypervisor_linux_arm64": ch, | ||
| 98 | "CLOUDHV.fd": fw, | ||
| 99 | } | ||
| 100 | for name, body := range files { | ||
| 101 | if err := os.WriteFile(filepath.Join(dist, name), body, 0o755); err != nil { | ||
| 102 | t.Fatal(err) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | m, err := BuildManifest("v0.0.1", dist, "https://eitri.sh/dl/v0.0.1") | ||
| 107 | if err != nil { | ||
| 108 | t.Fatal(err) | ||
| 109 | } | ||
| 110 | |||
| 111 | ch256 := sha256.Sum256(ch) | ||
| 112 | chArtifacts := m.Artifacts["cloud-hypervisor"] | ||
| 113 | if len(chArtifacts) != 2 { | ||
| 114 | t.Fatalf("want 2 cloud-hypervisor platforms, got %v", chArtifacts) | ||
| 115 | } | ||
| 116 | wantCH := release.Artifact{ | ||
| 117 | URL: "https://eitri.sh/dl/v0.0.1/cloud-hypervisor_linux_amd64", | ||
| 118 | SHA256: hex.EncodeToString(ch256[:]), | ||
| 119 | } | ||
| 120 | if chArtifacts["linux/amd64"] != wantCH { | ||
| 121 | t.Errorf("cloud-hypervisor linux/amd64 = %+v, want %+v", chArtifacts["linux/amd64"], wantCH) | ||
| 122 | } | ||
| 123 | |||
| 124 | fw256 := sha256.Sum256(fw) | ||
| 125 | fwArtifacts := m.Artifacts["firmware"] | ||
| 126 | if len(fwArtifacts) != 1 { | ||
| 127 | t.Fatalf("want 1 firmware platform, got %v", fwArtifacts) | ||
| 128 | } | ||
| 129 | wantFW := release.Artifact{ | ||
| 130 | URL: "https://eitri.sh/dl/v0.0.1/CLOUDHV.fd", | ||
| 131 | SHA256: hex.EncodeToString(fw256[:]), | ||
| 132 | } | ||
| 133 | if fwArtifacts["linux/amd64"] != wantFW { | ||
| 134 | t.Errorf("firmware linux/amd64 = %+v, want %+v", fwArtifacts["linux/amd64"], wantFW) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | func TestBuildManifestRuntimeArtifactsOptional(t *testing.T) { | ||
| 139 | dist := t.TempDir() | ||
| 140 | if err := os.WriteFile(filepath.Join(dist, "eitri-agent_linux_amd64"), []byte("x"), 0o755); err != nil { | ||
| 141 | t.Fatal(err) | ||
| 142 | } | ||
| 143 | |||
| 144 | m, err := BuildManifest("v0.0.1", dist, "https://eitri.sh/dl/v0.0.1") | ||
| 145 | if err != nil { | ||
| 146 | t.Fatal(err) | ||
| 147 | } | ||
| 148 | if _, ok := m.Artifacts["cloud-hypervisor"]; ok { | ||
| 149 | t.Errorf("cloud-hypervisor key present without any cloud-hypervisor binaries: %v", m.Artifacts["cloud-hypervisor"]) | ||
| 150 | } | ||
| 151 | if _, ok := m.Artifacts["firmware"]; ok { | ||
| 152 | t.Errorf("firmware key present without CLOUDHV.fd: %v", m.Artifacts["firmware"]) | ||
| 153 | } | ||
| 154 | } | ||
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,7 @@ declare -A FLOOR=( | |||
| 31 | [internal/server/registry]=95 | 31 | [internal/server/registry]=95 |
| 32 | [internal/server/release]=90 | 32 | [internal/server/release]=90 |
| 33 | [internal/agent/selfupdate]=65 | 33 | [internal/agent/selfupdate]=65 |
| 34 | [internal/agent/bootstrap]=70 | ||
| 34 | [internal/server/hosttoken]=95 | 35 | [internal/server/hosttoken]=95 |
| 35 | [internal/server/hub]=90 | 36 | [internal/server/hub]=90 |
| 36 | [internal/server/syncsvc]=72 | 37 | [internal/server/syncsvc]=72 |
scripts/eitri-agent.service
| Old | New | ||
|---|---|---|---|
| @@ -21,7 +21,9 @@ StartLimitBurst=10 | |||
| 21 | # Flags (resource caps, paths) go in /etc/default/eitri-agent as | 21 | # Flags (resource caps, paths) go in /etc/default/eitri-agent as |
| 22 | # EITRI_AGENT_FLAGS="--max-vcpus 8 ..."; the defaults suit a standard install | 22 | # EITRI_AGENT_FLAGS="--max-vcpus 8 ..."; the defaults suit a standard install |
| 23 | # (state under /var/lib/eitri-agent, cloud-hypervisor on PATH, firmware at | 23 | # (state under /var/lib/eitri-agent, cloud-hypervisor on PATH, firmware at |
| 24 | # /usr/share/eitri/CLOUDHV.fd). | 24 | # /usr/share/eitri/CLOUDHV.fd). The empty Environment= default keeps systemd |
| 25 | # from logging an unset-variable reference when no override file exists. | ||
| 26 | Environment=EITRI_AGENT_FLAGS= | ||
| 25 | EnvironmentFile=-/etc/default/eitri-agent | 27 | EnvironmentFile=-/etc/default/eitri-agent |
| 26 | ExecStart=/usr/local/bin/eitri-agent $EITRI_AGENT_FLAGS | 28 | ExecStart=/usr/local/bin/eitri-agent $EITRI_AGENT_FLAGS |
| 27 | Restart=on-failure | 29 | Restart=on-failure |
scripts/release.sh
| Old | New | ||
|---|---|---|---|
| @@ -4,9 +4,11 @@ | |||
| 4 | # eitri-ssh_<v>.tar.gz client bundle: eitri-ssh + eitri-ca (portable bash) | 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 | 5 | # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent |
| 6 | # self-updater downloads and sha-verifies | 6 | # self-updater downloads and sha-verifies |
| 7 | # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream | ||
| 8 | # CLOUDHV.fd guest UEFI firmware (if FIRMWARE_SRC set) | ||
| 7 | # SHA256SUMS over everything above | 9 | # SHA256SUMS over everything above |
| 8 | # manifest.json agent-upgrade manifest (shared type | 10 | # manifest.json agent-upgrade + agent-bootstrap manifest |
| 9 | # with internal/server/release) | 11 | # (shared type with internal/relmanifest) |
| 10 | # | 12 | # |
| 11 | # MANIFEST_BASE (env, optional) overrides the URL base written into | 13 | # MANIFEST_BASE (env, optional) overrides the URL base written into |
| 12 | # manifest.json — default https://eitri.sh/dl/<version>; set it for staging. | 14 | # manifest.json — default https://eitri.sh/dl/<version>; set it for staging. |
| @@ -18,6 +20,12 @@ | |||
| 18 | set -euo pipefail | 20 | set -euo pipefail |
| 19 | cd "$(dirname "$0")/.." | 21 | cd "$(dirname "$0")/.." |
| 20 | 22 | ||
| 23 | # Pinned cloud-hypervisor mirrored into every release (agents bootstrap it). | ||
| 24 | # Bump deliberately; update the sha256s from the upstream release page. | ||
| 25 | CH_VERSION="v53.0" | ||
| 26 | CH_SHA256_AMD64="448af3d4e59b22c2987f7df94c213ad40fb53a10d437e42b5ee6c4fce7c29ecc" | ||
| 27 | CH_SHA256_ARM64="f192b510eea1c710cbc439d716bb0573c223fc463dbe3e6523788a2b7ef62850" | ||
| 28 | |||
| 21 | VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" | 29 | VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" |
| 22 | case "$VERSION" in | 30 | case "$VERSION" in |
| 23 | *-dirty|dev) | 31 | *-dirty|dev) |
| @@ -60,6 +68,37 @@ mkdir -p "$stage/eitri-ssh_$VERSION" | |||
| 60 | cp hack/eitri-ssh hack/eitri-ca "$stage/eitri-ssh_$VERSION/" | 68 | 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" | 69 | tar -C "$stage" -czf "$OUT/eitri-ssh_$VERSION.tar.gz" "eitri-ssh_$VERSION" |
| 62 | 70 | ||
| 71 | # Mirror the pinned cloud-hypervisor (agents bootstrap it from the manifest). | ||
| 72 | ch_cache="${CH_CACHE:-$HOME/.cache/eitri/ch}/$CH_VERSION" | ||
| 73 | mkdir -p "$ch_cache" | ||
| 74 | for arch in amd64 arm64; do | ||
| 75 | case "$arch" in | ||
| 76 | amd64) asset="cloud-hypervisor-static"; want="$CH_SHA256_AMD64" ;; | ||
| 77 | arm64) asset="cloud-hypervisor-static-aarch64"; want="$CH_SHA256_ARM64" ;; | ||
| 78 | esac | ||
| 79 | cached="$ch_cache/$asset" | ||
| 80 | if [ ! -f "$cached" ]; then | ||
| 81 | curl -fsSL -o "$cached.part" \ | ||
| 82 | "https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/$CH_VERSION/$asset" | ||
| 83 | mv "$cached.part" "$cached" | ||
| 84 | fi | ||
| 85 | echo "$want $cached" | sha256sum -c - >/dev/null || { | ||
| 86 | rm -f "$cached" | ||
| 87 | echo "release: $asset sha mismatch (want $want) — cache purged, re-run" >&2; exit 1; } | ||
| 88 | install -m 0755 "$cached" "$OUT/cloud-hypervisor_linux_${arch}" | ||
| 89 | done | ||
| 90 | |||
| 91 | # Firmware rides in the release too (sha-pinned via SHA256SUMS + manifest). | ||
| 92 | FIRMWARE_SRC="${FIRMWARE_SRC:-}" | ||
| 93 | if [ -z "$FIRMWARE_SRC" ] && [ -f "$HOME/.cache/eitri/CLOUDHV.fd" ]; then | ||
| 94 | FIRMWARE_SRC="$HOME/.cache/eitri/CLOUDHV.fd" | ||
| 95 | fi | ||
| 96 | if [ -n "$FIRMWARE_SRC" ]; then | ||
| 97 | install -m 0644 "$FIRMWARE_SRC" "$OUT/CLOUDHV.fd" | ||
| 98 | else | ||
| 99 | echo "release: WARNING — no FIRMWARE_SRC; release ships no firmware and agents cannot bootstrap it" >&2 | ||
| 100 | fi | ||
| 101 | |||
| 63 | # The API contract rides in the release too (drift-gated in ci, so the | 102 | # The API contract rides in the release too (drift-gated in ci, so the |
| 64 | # committed copy is authoritative). | 103 | # committed copy is authoritative). |
| 65 | cp docs/openapi.json "$OUT/openapi.json" | 104 | cp docs/openapi.json "$OUT/openapi.json" |