a73x

5080647e

fix(server): a one-click create takes the image its host can run

a73x   2026-08-06 09:12

Commit message
fix(server): a one-click create takes the image its host can run

The control plane held one default image for the whole fleet, which is only
correct while every host shares an architecture. It has not since a Mac could
join: a one-click create on Apple silicon was handed the amd64 image configured
for the Linux hosts, Virtualization.framework could not execute it, and the
guest booted into nothing — no serial output, no DHCP, no address. The only
trace was the hypervisor exiting and the VM being reaped as "ephemeral VM lost",
which names neither the image nor the architecture.

default_images is keyed by the architecture of the host the VM lands on. The
create handler already read that host — for the tenant gate and the host-cert
principal — just after applying defaults; it now reads it before, so the arch is
in scope. Moving it also tightens the gate: a caller who may not see a host now
gets "unknown host_id" whatever else is wrong with the body.

Keyed by arch alone rather than os/arch, because the guest is Linux whichever
host runs it — a Mac hosts Linux arm64 guests. The host's OS picks the backend;
only the CPU has to match the image.

An architecture with no configured image is refused at create time, naming it.
The retired single-image keys warn and are IGNORED rather than standing in for
an unlisted architecture: a digest says nothing about what an image can execute,
so one image for every host is exactly how the wrong one boots. Ignoring them
cannot start a wrong guest, and the server still boots, so upgrading a config is
never an outage.

An explicitly supplied image is not arch-checked. A URL does not say what its
contents can execute, and guessing from a filename would reject legitimate
custom images to catch a mistake the operator made on purpose.

docs/quickstart.md
Old New
@@ -137,9 +137,10 @@ Set `SERVER_ADDR`, paste the rest:
137 ```sh 137 ```sh
138 SERVER_ADDR=192.0.2.10 138 SERVER_ADDR=192.0.2.10
139 IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current 139 IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current
140 IMAGE_FILE=resolute-server-cloudimg-amd64.img
141 HOST_SECRET=$(openssl rand -hex 32) 140 HOST_SECRET=$(openssl rand -hex 32)
142 IMAGE_SHA256=$(curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="$IMAGE_FILE" '$2 == "*" f {print $1}') 141 sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; }
142 AMD64_SHA=$(sha amd64)
143 ARM64_SHA=$(sha arm64)
143 144
144 sudo tee /etc/eitri/server.json >/dev/null <<EOF 145 sudo tee /etc/eitri/server.json >/dev/null <<EOF
145 { 146 {
@@ -155,8 +156,10 @@ sudo tee /etc/eitri/server.json >/dev/null <<EOF
155 "public_url": "http://$SERVER_ADDR:8080" 156 "public_url": "http://$SERVER_ADDR:8080"
156 }, 157 },
157 "host_secret": "$HOST_SECRET", 158 "host_secret": "$HOST_SECRET",
158 "default_image_url": "$IMAGE_DIR/$IMAGE_FILE", 159 "default_images": {
159 "default_image_sha256": "$IMAGE_SHA256", 160 "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"},
161 "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"}
162 },
160 "ssh_listen": ":2222", 163 "ssh_listen": ":2222",
161 "ssh_gate_domain": "$SERVER_ADDR", 164 "ssh_gate_domain": "$SERVER_ADDR",
162 "ssh_ca_key": "/var/lib/eitri/ssh_ca", 165 "ssh_ca_key": "/var/lib/eitri/ssh_ca",
@@ -170,9 +173,16 @@ The chmod matters: `server.json` carries `host_secret`, so it is root-owned
170 and readable only via the `eitri` group—not world-readable. 173 and readable only via the `eitri` group—not world-readable.
171 174
172 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any 175 `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any
173 cloud-init disk image works as the default image; the Ubuntu one boots out of 176 cloud-init disk image works as a default image; the Ubuntu one boots out of
174 the box. 177 the box.
175 178
179 `default_images` is keyed by the architecture of the host a VM lands on, and a
180 VM created without an explicit image takes the entry for its host. Configure
181 every architecture in your fleet: a host can only run a guest built for its own
182 CPU, and a create for an architecture you have not listed is refused rather than
183 served an image that cannot boot. One entry is plenty for a single-architecture
184 fleet—both are shown because an Apple silicon host takes `arm64`.
185
176 The `oidc` block points the console's sign-in at the bundled issuer you start 186 The `oidc` block points the console's sign-in at the bundled issuer you start
177 next. `public_url` is where browsers reach the console (the callback lands at 187 next. `public_url` is where browsers reach the console (the callback lands at
178 `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing 188 `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing
internal/server/api/api.go
Old New
@@ -8,6 +8,7 @@ import (
8 "encoding/hex" 8 "encoding/hex"
9 "encoding/json" 9 "encoding/json"
10 "errors" 10 "errors"
11 "fmt"
11 "log/slog" 12 "log/slog"
12 "net/http" 13 "net/http"
13 "regexp" 14 "regexp"
@@ -39,11 +40,14 @@ type DefaultImage struct {
39 40
40 // Config holds static configuration for the API server. 41 // Config holds static configuration for the API server.
41 type Config struct { 42 type Config struct {
42 HostSecret []byte 43 HostSecret []byte
43 DefaultImage DefaultImage 44 // DefaultImages is keyed by host ARCHITECTURE ("amd64", "arm64"). A
45 // one-click create takes the entry matching the host it is placed on; see
46 // applyVMDefaults.
47 DefaultImages map[string]DefaultImage
44 ServerCertSHA256 string 48 ServerCertSHA256 string
45 AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port) 49 AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port)
46 AdvertiseQUIC string // QUIC host:port agents use to reach this server 50 AdvertiseQUIC string // QUIC host:port agents use to reach this server
47 OIDC OIDCConfig // console sign-in relying-party settings (see auth.go) 51 OIDC OIDCConfig // console sign-in relying-party settings (see auth.go)
48 } 52 }
49 53
@@ -622,17 +626,32 @@ func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) {
622 writeJSON(w, http.StatusOK, out) 626 writeJSON(w, http.StatusOK, out)
623 } 627 }
624 628
625 // applyVMDefaults fills one-click defaults in place. It returns an error message 629 // applyVMDefaults fills one-click defaults in place. hostArch is the
626 // and HTTP status (msg=="" when ok) for the image-pairing rule, which is a 630 // architecture of the host the VM is being placed on, which selects the default
627 // validation, not a default. 631 // image: a host can only execute a guest built for its own CPU, and nothing
628 func (a *API) applyVMDefaults(req *types.CreateVMRequest) (string, int) { 632 // downstream checks — an image the host cannot run boots into nothing and is
633 // reported as a lost VM, with no clue as to why.
634 //
635 // An explicitly supplied image is NOT arch-checked. A URL does not say what its
636 // contents can execute, and guessing from the filename would reject legitimate
637 // custom images to catch a mistake the operator made deliberately. The guard is
638 // on the default, which eitri chooses, not on the choice the caller made.
639 //
640 // It returns an error message and HTTP status (msg=="" when ok) for the
641 // image-pairing rule, which is a validation, not a default.
642 func (a *API) applyVMDefaults(req *types.CreateVMRequest, hostArch string) (string, int) {
629 if req.Name == "" { 643 if req.Name == "" {
630 req.Name = "sandbox-" + random.Hex(3) 644 req.Name = "sandbox-" + random.Hex(3)
631 } 645 }
632 // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty. 646 // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty.
633 if req.ImageURL == "" && req.ImageSHA256 == "" { 647 if req.ImageURL == "" && req.ImageSHA256 == "" {
634 req.ImageURL = a.cfg.DefaultImage.URL 648 img, ok := a.cfg.DefaultImages[hostArch]
635 req.ImageSHA256 = a.cfg.DefaultImage.SHA256 649 if !ok {
650 return fmt.Sprintf("no default image configured for %s hosts; pass image_url and image_sha256, "+
651 "or add default_images[%q] to the server config", hostArch, hostArch), http.StatusBadRequest
652 }
653 req.ImageURL = img.URL
654 req.ImageSHA256 = img.SHA256
636 } else if req.ImageURL == "" || req.ImageSHA256 == "" { 655 } else if req.ImageURL == "" || req.ImageSHA256 == "" {
637 return "image_url and image_sha256 must be provided together", http.StatusBadRequest 656 return "image_url and image_sha256 must be provided together", http.StatusBadRequest
638 } 657 }
@@ -689,18 +708,14 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
689 return 708 return
690 } 709 }
691 710
692 if msg, code := a.applyVMDefaults(&req); msg != "" {
693 http.Error(w, msg, code)
694 return
695 }
696 if msg, code := validateCreateVM(&req); msg != "" {
697 http.Error(w, msg, code)
698 return
699 }
700
701 // Tenant gate: you may only place VMs on hosts in your tenant. The host 711 // Tenant gate: you may only place VMs on hosts in your tenant. The host
702 // read also feeds the namespaced host-cert principal below. CreateVM 712 // read also feeds the namespaced host-cert principal below, and the default
703 // re-checks host status in-tx; this pre-read is the AUTHZ point. 713 // image, which follows the host's architecture. CreateVM re-checks host
714 // status in-tx; this pre-read is the AUTHZ point.
715 //
716 // It runs BEFORE defaults and validation: a caller who may not see this host
717 // gets "unknown host_id" whatever else is wrong with the body, rather than a
718 // validation error that confirms the request got as far as the host.
704 host, err := a.st.GetHost(req.HostID) 719 host, err := a.st.GetHost(req.HostID)
705 if err != nil { 720 if err != nil {
706 if errors.Is(err, sql.ErrNoRows) { 721 if errors.Is(err, sql.ErrNoRows) {
@@ -716,6 +731,15 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) {
716 return 731 return
717 } 732 }
718 733
734 if msg, code := a.applyVMDefaults(&req, host.Arch); msg != "" {
735 http.Error(w, msg, code)
736 return
737 }
738 if msg, code := validateCreateVM(&req); msg != "" {
739 http.Error(w, msg, code)
740 return
741 }
742
719 // BYO CA precondition: a VM with no trusted user CA baked at create is 743 // BYO CA precondition: a VM with no trusted user CA baked at create is
720 // unreachable. Require the tenant to have registered ≥1 user CA first. 744 // unreachable. Require the tenant to have registered ≥1 user CA first.
721 if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil { 745 if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil {
internal/server/api/api_test.go
Old New
@@ -3,6 +3,7 @@ package api
3 import ( 3 import (
4 "bytes" 4 "bytes"
5 "encoding/json" 5 "encoding/json"
6 "io"
6 "maps" 7 "maps"
7 "net/http" 8 "net/http"
8 "net/http/httptest" 9 "net/http/httptest"
@@ -124,6 +125,71 @@ func TestCreateVMDuplicateNameReturns409(t *testing.T) {
124 assert.NotContains(t, string(bodyStr), "UNIQUE", "raw SQLite error must not leak into response") 125 assert.NotContains(t, string(bodyStr), "UNIQUE", "raw SQLite error must not leak into response")
125 } 126 }
126 127
128 // enrollArch enrols a host with a given name/os/arch, so a test can place a VM
129 // on something other than the default linux/amd64 host.
130 func enrollArch(t *testing.T, ts *httptest.Server, name, os, arch, prov string) map[string]string {
131 t.Helper()
132 resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
133 require.Equal(t, 201, resp.StatusCode)
134 var tok map[string]string
135 json.NewDecoder(resp.Body).Decode(&tok)
136 resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
137 "token": tok["token"], "name": name, "os": os, "arch": arch, "provisioner": prov})
138 require.Equal(t, 201, resp.StatusCode)
139 var out map[string]string
140 json.NewDecoder(resp.Body).Decode(&out)
141 return out
142 }
143
144 // TestCreateVMDefaultImageFollowsHostArch pins the rule a mixed-arch fleet
145 // depends on: a one-click create takes the default image for the architecture of
146 // the host it lands on. The fleet-wide default that preceded this handed an
147 // arm64 Mac an amd64 image, which boots into nothing and surfaces only as
148 // "ephemeral VM lost" once the hypervisor exits.
149 func TestCreateVMDefaultImageFollowsHostArch(t *testing.T) {
150 ts, st, _ := testServer(t)
151 linux := enroll(t, ts) // linux/amd64
152 mac := enrollArch(t, ts, "host-m", "darwin", "arm64", "vfkit") //nolint:misspell // vfkit
153 riscv := enrollArch(t, ts, "host-r", "linux", "riscv64", "cloudhv") // no configured image
154
155 // testServer configures amd64 and arm64 (see the Config literal above).
156 for _, tc := range []struct{ name, hostID, wantImage string }{
157 {"amd64 host", linux["host_id"], "amd64"},
158 {"arm64 host", mac["host_id"], "arm64"},
159 } {
160 t.Run(tc.name, func(t *testing.T) {
161 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
162 map[string]any{"host_id": tc.hostID, "name": "vm-" + tc.wantImage})
163 require.Equal(t, 201, resp.StatusCode)
164 var out map[string]string
165 json.NewDecoder(resp.Body).Decode(&out)
166 vm, err := st.GetVM(out["id"])
167 require.NoError(t, err)
168 assert.Contains(t, vm.ImageURL, tc.wantImage,
169 "the default image must match the host's architecture")
170 })
171 }
172
173 // An architecture with no configured image is a 400 that names it — not a
174 // silent fallback to some other arch's image.
175 t.Run("unconfigured arch refuses", func(t *testing.T) {
176 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
177 map[string]any{"host_id": riscv["host_id"], "name": "vm-riscv"})
178 require.Equal(t, 400, resp.StatusCode)
179 body, _ := io.ReadAll(resp.Body)
180 assert.Contains(t, string(body), "riscv64", "the error must name the architecture")
181 })
182
183 // An EXPLICIT image is never arch-checked: a URL says nothing about what it
184 // can execute, and guessing would reject legitimate custom images.
185 t.Run("explicit image is not second-guessed", func(t *testing.T) {
186 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
187 "host_id": mac["host_id"], "name": "vm-explicit",
188 "image_url": "https://example.test/my-amd64-build.img", "image_sha256": strings.Repeat("b", 64)})
189 assert.Equal(t, 201, resp.StatusCode)
190 })
191 }
192
127 // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400. 193 // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400.
128 func TestCreateVMUnknownHostReturns400(t *testing.T) { 194 func TestCreateVMUnknownHostReturns400(t *testing.T) {
129 ts, _, _ := testServer(t) 195 ts, _, _ := testServer(t)
@@ -217,9 +283,14 @@ func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registr
217 reg := registry.New(time.Now) 283 reg := registry.New(time.Now)
218 a := New(Config{ 284 a := New(Config{
219 HostSecret: []byte("hostsecret"), 285 HostSecret: []byte("hostsecret"),
220 DefaultImage: DefaultImage{ 286 DefaultImages: map[string]DefaultImage{
221 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", 287 "amd64": {
222 SHA256: strings.Repeat("a", 64)}, 288 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
289 SHA256: strings.Repeat("a", 64)},
290 "arm64": {
291 URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
292 SHA256: strings.Repeat("a", 64)},
293 },
223 AdvertiseHTTP: "http://127.0.0.1:8080", 294 AdvertiseHTTP: "http://127.0.0.1:8080",
224 AdvertiseQUIC: "127.0.0.1:8443", 295 AdvertiseQUIC: "127.0.0.1:8443",
225 ServerCertSHA256: strings.Repeat("c", 64), 296 ServerCertSHA256: strings.Repeat("c", 64),
internal/server/api/decommission_api_test.go
Old New
@@ -26,7 +26,7 @@ func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
26 seedTestTenant(t, st) 26 seedTestTenant(t, st)
27 a := New(Config{ 27 a := New(Config{
28 HostSecret: []byte("hostsecret"), 28 HostSecret: []byte("hostsecret"),
29 DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)}, 29 DefaultImages: map[string]DefaultImage{"amd64": {URL: "http://img", SHA256: strings.Repeat("a", 64)}},
30 AdvertiseHTTP: "http://127.0.0.1:8080", 30 AdvertiseHTTP: "http://127.0.0.1:8080",
31 AdvertiseQUIC: "127.0.0.1:8443", 31 AdvertiseQUIC: "127.0.0.1:8443",
32 ServerCertSHA256: strings.Repeat("c", 64), 32 ServerCertSHA256: strings.Repeat("c", 64),
internal/server/boot/boot.go
Old New
@@ -141,7 +141,7 @@ func run(cfgPath string) error {
141 h := hub.New() 141 h := hub.New()
142 142
143 a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret), 143 a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret),
144 DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA}, 144 DefaultImages: defaultImages(cfg.DefaultImages),
145 ServerCertSHA256: certFP, 145 ServerCertSHA256: certFP,
146 AdvertiseHTTP: cfg.AdvertiseHTTP, 146 AdvertiseHTTP: cfg.AdvertiseHTTP,
147 AdvertiseQUIC: cfg.AdvertiseQUIC, 147 AdvertiseQUIC: cfg.AdvertiseQUIC,
@@ -266,3 +266,14 @@ func run(cfgPath string) error {
266 a.Close() 266 a.Close()
267 return nil 267 return nil
268 } 268 }
269
270 // defaultImages translates the config's per-architecture guest images into the
271 // API's own type, so the API package does not import the config schema (R1: the
272 // wiring converts, the leaves stay independent).
273 func defaultImages(in map[string]serverconfig.DefaultImage) map[string]api.DefaultImage {
274 out := make(map[string]api.DefaultImage, len(in))
275 for arch, img := range in {
276 out[arch] = api.DefaultImage{URL: img.URL, SHA256: img.SHA256}
277 }
278 return out
279 }
internal/server/config/config.go
Old New
@@ -10,13 +10,26 @@ type Config struct {
10 // AdminToken is retired: sign-in is OIDC (see OIDC below) and console 10 // AdminToken is retired: sign-in is OIDC (see OIDC below) and console
11 // credentials are sessions/PATs. The field is kept only so the server can 11 // credentials are sessions/PATs. The field is kept only so the server can
12 // detect a stale token in an old config and warn the operator to remove it. 12 // detect a stale token in an old config and warn the operator to remove it.
13 AdminToken string `json:"admin_token"` 13 AdminToken string `json:"admin_token"`
14 HostSecret string `json:"host_secret"` 14 HostSecret string `json:"host_secret"`
15 CIDRPool string `json:"cidr_pool"` 15 CIDRPool string `json:"cidr_pool"`
16 // DefaultImageURL/SHA are retired in favour of DefaultImages: one image for
17 // the whole fleet is only correct while every host shares an architecture.
18 // Kept so the server can spot them in an old config and say what to write.
16 DefaultImageURL string `json:"default_image_url"` 19 DefaultImageURL string `json:"default_image_url"`
17 DefaultImageSHA string `json:"default_image_sha256"` 20 DefaultImageSHA string `json:"default_image_sha256"`
18 AdvertiseHTTP string `json:"advertise_http"` 21 // DefaultImages is the guest image a one-click create applies, keyed by the
19 AdvertiseQUIC string `json:"advertise_quic"` 22 // ARCHITECTURE of the host the VM is being placed on ("amd64", "arm64" —
23 // the GOARCH each agent reports at enrollment).
24 //
25 // Keyed by arch alone, not os/arch: the guest is Linux whichever host runs
26 // it — a Mac hosts Linux arm64 guests through Virtualization.framework. The
27 // host's OS picks the backend; only the CPU architecture has to match the
28 // image, and handing a host an image it cannot execute is the one mistake
29 // this map exists to prevent.
30 DefaultImages map[string]DefaultImage `json:"default_images"`
31 AdvertiseHTTP string `json:"advertise_http"`
32 AdvertiseQUIC string `json:"advertise_quic"`
20 // CredentialMaxAge optionally bounds host credential age (Go duration, 33 // CredentialMaxAge optionally bounds host credential age (Go duration,
21 // e.g. "2160h" for 90 days). Empty/zero disables — revocation via 34 // e.g. "2160h" for 90 days). Empty/zero disables — revocation via
22 // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; 35 // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism;
@@ -52,14 +65,21 @@ type Config struct {
52 OIDC OIDC `json:"oidc"` 65 OIDC OIDC `json:"oidc"`
53 } 66 }
54 67
68 // DefaultImage is one architecture's guest image: the URL to fetch and the
69 // digest the agent verifies it against.
70 type DefaultImage struct {
71 URL string `json:"url"`
72 SHA256 string `json:"sha256"`
73 }
74
55 // OIDC configures the server's relying-party side. The issuer is sometimes 75 // OIDC configures the server's relying-party side. The issuer is sometimes
56 // the bundled eitri-oidc next door and sometimes an external IdP — the 76 // the bundled eitri-oidc next door and sometimes an external IdP — the
57 // server cannot tell the difference (spec §2). 77 // server cannot tell the difference (spec §2).
58 type OIDC struct { 78 type OIDC struct {
59 Issuer string `json:"issuer"` 79 Issuer string `json:"issuer"`
60 ClientID string `json:"client_id"` 80 ClientID string `json:"client_id"`
61 ClientSecret string `json:"client_secret"` // external confidential clients only 81 ClientSecret string `json:"client_secret"` // external confidential clients only
62 PublicURL string `json:"public_url"` 82 PublicURL string `json:"public_url"`
63 AllowedDomains []string `json:"allowed_domains"` // optional signup gate 83 AllowedDomains []string `json:"allowed_domains"` // optional signup gate
64 AllowedIdentities []string `json:"allowed_identities"` // optional signup gate 84 AllowedIdentities []string `json:"allowed_identities"` // optional signup gate
65 } 85 }
internal/server/config/load.go
Old New
@@ -8,9 +8,11 @@ import (
8 "encoding/json" 8 "encoding/json"
9 "fmt" 9 "fmt"
10 "log/slog" 10 "log/slog"
11 "maps"
11 "net/url" 12 "net/url"
12 "os" 13 "os"
13 "regexp" 14 "regexp"
15 "slices"
14 "strings" 16 "strings"
15 "time" 17 "time"
16 ) 18 )
@@ -40,6 +42,18 @@ func Load(path string) (Config, error) {
40 if cfg.AdminToken != "" { 42 if cfg.AdminToken != "" {
41 slog.Warn("server.json: admin_token is no longer used and is ignored; remove it") 43 slog.Warn("server.json: admin_token is no longer used and is ignored; remove it")
42 } 44 }
45 // default_image_url/sha are retired in favour of default_images. They are
46 // IGNORED rather than used as a fallback for an unlisted architecture: the
47 // digest says nothing about what the image can execute, so honouring one
48 // image for every host is how an amd64 image reaches an arm64 Mac and dies
49 // as "ephemeral VM lost". A one-click create for an unlisted arch fails
50 // instead, at create time, naming the arch — and the server still boots, so
51 // this never turns a config upgrade into an outage.
52 if cfg.DefaultImageURL != "" || cfg.DefaultImageSHA != "" {
53 slog.Warn("server.json: default_image_url/default_image_sha256 are no longer used and are IGNORED; " +
54 `move them under default_images keyed by host architecture, e.g. ` +
55 `"default_images": {"amd64": {"url": "...", "sha256": "..."}}`)
56 }
43 return cfg, nil 57 return cfg, nil
44 } 58 }
45 59
@@ -72,10 +86,19 @@ func validate(cfg Config) error {
72 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" { 86 if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" {
73 return fmt.Errorf("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)") 87 return fmt.Errorf("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)")
74 } 88 }
75 // Fail fast on a malformed default image digest rather than letting every 89 // Fail fast on a malformed default image rather than letting every one-click
76 // mint silently propagate a bad hash. 90 // create propagate a bad hash. Sorted so a config with several bad entries
77 if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) { 91 // reports them in a stable order.
78 return fmt.Errorf("default_image_sha256 malformed (want 64 lowercase hex chars), got %q", cfg.DefaultImageSHA) 92 for _, arch := range slices.Sorted(maps.Keys(cfg.DefaultImages)) {
93 img := cfg.DefaultImages[arch]
94 switch {
95 case arch == "":
96 return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`)
97 case img.URL == "":
98 return fmt.Errorf("default_images[%q].url is required", arch)
99 case !defaultImageSHARe.MatchString(img.SHA256):
100 return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256)
101 }
79 } 102 }
80 return nil 103 return nil
81 } 104 }
internal/server/config/load_test.go
Old New
@@ -93,16 +93,54 @@ func TestLoadRequiresAdvertiseAddrs(t *testing.T) {
93 } 93 }
94 } 94 }
95 95
96 func TestLoadRejectsMalformedImageSHA(t *testing.T) { 96 // withImages splices a default_images block into the minimal config.
97 withSHA := strings.Replace(minimal, `"host_secret": "s3cret",`, 97 func withImages(body string) string {
98 `"host_secret": "s3cret", "default_image_sha256": "NOTHEX",`, 1) 98 return strings.Replace(minimal, `"host_secret": "s3cret",`,
99 if _, err := Load(write(t, withSHA)); err == nil || !strings.Contains(err.Error(), "default_image_sha256") { 99 `"host_secret": "s3cret", "default_images": {`+body+`},`, 1)
100 t.Errorf("got %v", err) 100 }
101
102 func TestLoadValidatesDefaultImages(t *testing.T) {
103 good := strings.Repeat("a", 64)
104 for _, tc := range []struct {
105 name, body, want string // want=="" ⇒ must load cleanly
106 }{
107 {"one arch", `"amd64": {"url": "http://i", "sha256": "` + good + `"}`, ""},
108 {"both arches", `"amd64": {"url": "http://i", "sha256": "` + good + `"},
109 "arm64": {"url": "http://j", "sha256": "` + good + `"}`, ""},
110 {"absent entirely", "", ""},
111 {"malformed sha", `"arm64": {"url": "http://i", "sha256": "NOTHEX"}`, `default_images["arm64"].sha256`},
112 {"missing url", `"amd64": {"sha256": "` + good + `"}`, `default_images["amd64"].url`},
113 {"empty arch key", `"": {"url": "http://i", "sha256": "` + good + `"}`, "empty architecture key"},
114 } {
115 t.Run(tc.name, func(t *testing.T) {
116 _, err := Load(write(t, withImages(tc.body)))
117 if tc.want == "" {
118 if err != nil {
119 t.Fatalf("valid config rejected: %v", err)
120 }
121 return
122 }
123 if err == nil || !strings.Contains(err.Error(), tc.want) {
124 t.Fatalf("want error containing %q, got %v", tc.want, err)
125 }
126 })
127 }
128 }
129
130 // TestLoadToleratesRetiredDefaultImageKeys pins that the old single-image keys
131 // warn but never fail: a config upgrade must not stop the control plane
132 // booting. They are ignored, so a one-click create for an unlisted arch fails
133 // at create time instead of handing a host an image it cannot execute.
134 func TestLoadToleratesRetiredDefaultImageKeys(t *testing.T) {
135 old := strings.Replace(minimal, `"host_secret": "s3cret",`,
136 `"host_secret": "s3cret", "default_image_url": "http://i",
137 "default_image_sha256": "`+strings.Repeat("a", 64)+`",`, 1)
138 cfg, err := Load(write(t, old))
139 if err != nil {
140 t.Fatalf("retired keys must not fail the load: %v", err)
101 } 141 }
102 okSHA := strings.Replace(minimal, `"host_secret": "s3cret",`, 142 if len(cfg.DefaultImages) != 0 {
103 `"host_secret": "s3cret", "default_image_sha256": "`+strings.Repeat("a", 64)+`",`, 1) 143 t.Errorf("retired keys must not populate DefaultImages, got %v", cfg.DefaultImages)
104 if _, err := Load(write(t, okSHA)); err != nil {
105 t.Errorf("valid sha rejected: %v", err)
106 } 144 }
107 } 145 }
108 146