a73x

9101e104

feat(agent): bootstrap cloud-hypervisor and the guest firmware

a73x   2026-07-26 15:44

Commit message
feat(agent): bootstrap cloud-hypervisor and the guest firmware

A joining host needs only KVM and qemu-img. At startup the agent fetches
anything missing from the release manifest — cloud-hypervisor (mirrored,
sha-pinned v53.0) and CLOUDHV.fd — verifies the sha, and installs with the
same durable temp-beside-destination swap the self-updater uses. Present
files mean zero network, so established and airgapped hosts never dial out;
--bootstrap-url= disables it outright.

--ch-bin and the install destination are two views of one name, mapped by
cloudhv.BootstrapDest: an explicit path is its own destination, while a
bare command name resolves via $PATH when already installed (Ensure
no-ops) and otherwise installs to /usr/local/bin, which the systemd
unit's default $PATH includes — the launch-time lookup always finds what
bootstrap installed. cloudhv keeps the os/exec use inside the R7
sanctioned package, and the agent unit sets an empty EITRI_AGENT_FLAGS
default so systemd never logs an unset-variable reference on hosts
without an override file.

The manifest type lives in internal/relmanifest, a wire-plane leaf shared by
the producer (eitri-site), the server (upgrade offers), and the agent — one
type, no drift. Firmware ships inside /dl/<version>/, sha-pinned like
everything else.

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"