a73x

120a24fd

fix(consolidation): centralize the defaults the review found drifted

a73x   2026-08-12 19:12

Commit message
fix(consolidation): centralize the defaults the review found drifted

The review found three facts each pinned in more than one place. Rather
than point the duplicates at one of the copies, give each a single named
home so nothing pins its own default.

VM create sizes. The REST API defaulted disk to 10 while the MCP layer
defaulted it to 20, and each side's test pinned its own number. The three
size defaults now live as types.DefaultVCPUs/DefaultMemMB/DefaultDiskGB
beside CreateVMRequest; the API's defaulting reads them, and the MCP layer
sets no size default at all — omitted sizes reach the control plane as
zero and inherit the named value. api_test asserts the constants; the MCP
test asserts the request carries no size of its own, so neither layer can
pin a divergent default again. The console form and quickstart restate the
numbers in non-Go surfaces, so a new types guard test (types_defaults_test.go)
reads both files and fails if either drifts from the constants.

Guest login user. "ubuntu" was named once (a boot.go const) but
re-literalled in the agent's cloud-init seed, the CLI cert minter, the CLI
ssh command, and the smoke default. It now lives in a new wire-plane leaf,
internal/guest.LoginUser — the wire plane is the only code shared across
the control and data planes, and the agent must never import the server,
so a fresh leaf is the one home both planes can read. Every Go site now
reads it: boot, agent/seed, cli/mint, cli/sshcmd, smoke/config. The shape
classifier learns the package (wire) and the R9 wire-plane sweep expects
it. gateclient carries the login user as a field that drives the cert
principal, and Dial takes the SSH login user from that same credential
(DialConfig.VMUser is gone), so the login user and the cert principal are
one value.

docs/mcp.md: line 6 claimed a PAT alone is enough, contradicting the
vm_create row that refuses a tenant with no registered SSH user CA.
Corrected to say creating VMs additionally needs a CA registered via
ca_upload.

docs/mcp.md
Old New
@@ -2,8 +2,10 @@
2 2
3 eitri gives Claude explicit tools for creating and controlling VMs on a fleet. 3 eitri gives Claude explicit tools for creating and controlling VMs on a fleet.
4 There is one place it speaks MCP: `/mcp`, served by the control plane itself. 4 There is one place it speaks MCP: `/mcp`, served by the control plane itself.
5 MCP streamable HTTP, authenticated with a personal access token. No install, no 5 MCP streamable HTTP, authenticated with a personal access token. No install and
6 config file, no CA of your own: a PAT is enough. 6 no config file: a PAT is enough to connect. Creating VMs additionally needs an
7 SSH user CA you register once with `ca_upload`; every other tool needs only the
8 token.
7 9
8 On the hosted plane that endpoint is `https://api.eitri.sh/mcp`. A plane you run 10 On the hosted plane that endpoint is `https://api.eitri.sh/mcp`. A plane you run
9 yourself serves the same endpoint on its own address, from the same binary — a 11 yourself serves the same endpoint on its own address, from the same binary — a
docs/shape.html
Old New
@@ -249,7 +249,9 @@
249 "importPath": "internal/agent/seed", 249 "importPath": "internal/agent/seed",
250 "plane": "data", 250 "plane": "data",
251 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", 251 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
252 "imports": [] 252 "imports": [
253 "internal/guest"
254 ]
253 }, 255 },
254 { 256 {
255 "importPath": "internal/agent/selfupdate", 257 "importPath": "internal/agent/selfupdate",
@@ -313,6 +315,7 @@
313 "plane": "tooling", 315 "plane": "tooling",
314 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", 316 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
315 "imports": [ 317 "imports": [
318 "internal/guest",
316 "internal/server/api/client" 319 "internal/server/api/client"
317 ] 320 ]
318 }, 321 },
@@ -335,6 +338,12 @@
335 "imports": [] 338 "imports": []
336 }, 339 },
337 { 340 {
341 "importPath": "internal/guest",
342 "plane": "wire",
343 "synopsis": "Package guest holds the handful of facts the control plane and the data plane must agree on about a guest VM.",
344 "imports": []
345 },
346 {
338 "importPath": "internal/joinblob", 347 "importPath": "internal/joinblob",
339 "plane": "wire", 348 "plane": "wire",
340 "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", 349 "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.",
@@ -429,6 +438,7 @@
429 "synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).", 438 "synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).",
430 "imports": [ 439 "imports": [
431 "internal/covsnap", 440 "internal/covsnap",
441 "internal/guest",
432 "internal/joinblob", 442 "internal/joinblob",
433 "internal/server/api", 443 "internal/server/api",
434 "internal/server/config", 444 "internal/server/config",
@@ -576,6 +586,7 @@
576 "synopsis": "Package smoke is the deploy boot-gate harness.", 586 "synopsis": "Package smoke is the deploy boot-gate harness.",
577 "imports": [ 587 "imports": [
578 "internal/gateclient", 588 "internal/gateclient",
589 "internal/guest",
579 "internal/server/api/client" 590 "internal/server/api/client"
580 ] 591 ]
581 }, 592 },
docs/shape.json
Old New
@@ -198,7 +198,9 @@
198 "importPath": "internal/agent/seed", 198 "importPath": "internal/agent/seed",
199 "plane": "data", 199 "plane": "data",
200 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).", 200 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
201 "imports": [] 201 "imports": [
202 "internal/guest"
203 ]
202 }, 204 },
203 { 205 {
204 "importPath": "internal/agent/selfupdate", 206 "importPath": "internal/agent/selfupdate",
@@ -262,6 +264,7 @@
262 "plane": "tooling", 264 "plane": "tooling",
263 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.", 265 "synopsis": "Package cli implements the eitri client binary: self-signed short-lived SSH certs with the tenant's own user CA, host verification pinned to eitri's host CA, sessions through the system ssh, and tenant CA registration.",
264 "imports": [ 266 "imports": [
267 "internal/guest",
265 "internal/server/api/client" 268 "internal/server/api/client"
266 ] 269 ]
267 }, 270 },
@@ -284,6 +287,12 @@
284 "imports": [] 287 "imports": []
285 }, 288 },
286 { 289 {
290 "importPath": "internal/guest",
291 "plane": "wire",
292 "synopsis": "Package guest holds the handful of facts the control plane and the data plane must agree on about a guest VM.",
293 "imports": []
294 },
295 {
287 "importPath": "internal/joinblob", 296 "importPath": "internal/joinblob",
288 "plane": "wire", 297 "plane": "wire",
289 "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.", 298 "synopsis": "Package joinblob encodes and decodes the single-paste enrollment token (\"join blob\") an agent uses to enroll: it carries the server's HTTP base URL, its QUIC address, a one-shot enrollment token, and the server's TLS cert fingerprint for out-of-band pinning.",
@@ -378,6 +387,7 @@
378 "synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).", 387 "synopsis": "Package boot implements the eitri-server command line behind a tested RunCLI so cmd/eitri-server stays thin wiring (arch R14).",
379 "imports": [ 388 "imports": [
380 "internal/covsnap", 389 "internal/covsnap",
390 "internal/guest",
381 "internal/joinblob", 391 "internal/joinblob",
382 "internal/server/api", 392 "internal/server/api",
383 "internal/server/config", 393 "internal/server/config",
@@ -525,6 +535,7 @@
525 "synopsis": "Package smoke is the deploy boot-gate harness.", 535 "synopsis": "Package smoke is the deploy boot-gate harness.",
526 "imports": [ 536 "imports": [
527 "internal/gateclient", 537 "internal/gateclient",
538 "internal/guest",
528 "internal/server/api/client" 539 "internal/server/api/client"
529 ] 540 ]
530 }, 541 },
internal/agent/seed/seed.go
Old New
@@ -6,6 +6,7 @@ import (
6 "os" 6 "os"
7 "strings" 7 "strings"
8 8
9 "github.com/a73x/eitri/internal/guest"
9 diskfs "github.com/diskfs/go-diskfs" 10 diskfs "github.com/diskfs/go-diskfs"
10 "github.com/diskfs/go-diskfs/disk" 11 "github.com/diskfs/go-diskfs/disk"
11 "github.com/diskfs/go-diskfs/filesystem" 12 "github.com/diskfs/go-diskfs/filesystem"
@@ -254,10 +255,10 @@ func userData(p Params) string {
254 return fmt.Sprintf(`#cloud-config 255 return fmt.Sprintf(`#cloud-config
255 hostname: %s 256 hostname: %s
256 users: 257 users:
257 - name: ubuntu 258 - name: %s
258 sudo: ALL=(ALL) NOPASSWD:ALL 259 sudo: ALL=(ALL) NOPASSWD:ALL
259 shell: /bin/bash%s 260 shell: /bin/bash%s
260 `, p.Hostname, sshKeys) 261 `, p.Hostname, guest.LoginUser, sshKeys)
261 } 262 }
262 263
263 // metaData returns the cloud-init meta-data content. 264 // metaData returns the cloud-init meta-data content.
internal/agent/seed/seed_test.go
Old New
@@ -6,6 +6,7 @@ import (
6 "strings" 6 "strings"
7 "testing" 7 "testing"
8 8
9 "github.com/a73x/eitri/internal/guest"
9 diskfs "github.com/diskfs/go-diskfs" 10 diskfs "github.com/diskfs/go-diskfs"
10 "github.com/diskfs/go-diskfs/filesystem/iso9660" 11 "github.com/diskfs/go-diskfs/filesystem/iso9660"
11 "github.com/stretchr/testify/assert" 12 "github.com/stretchr/testify/assert"
@@ -50,7 +51,7 @@ func TestUserDataDefaultInjectsKeyNotGrowpart(t *testing.T) {
50 ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"}) 51 ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"})
51 assert.True(t, strings.HasPrefix(ud, "#cloud-config\n")) 52 assert.True(t, strings.HasPrefix(ud, "#cloud-config\n"))
52 assert.Contains(t, ud, "ssh-ed25519 KEY") 53 assert.Contains(t, ud, "ssh-ed25519 KEY")
53 assert.Contains(t, ud, "name: ubuntu") 54 assert.Contains(t, ud, "name: "+guest.LoginUser)
54 // Growing the root to disk_gb is eitri's job and lives in vendor-data, not 55 // Growing the root to disk_gb is eitri's job and lives in vendor-data, not
55 // here: a growpart in user-data would override vendor-data's `growpart: off` 56 // here: a growpart in user-data would override vendor-data's `growpart: off`
56 // and re-arm the image growpart that reverts on this disk. 57 // and re-arm the image growpart that reverts on this disk.
@@ -67,7 +68,7 @@ func TestUserDataOmitsSSHAuthorizedKeysWhenNoKey(t *testing.T) {
67 assert.NotContains(t, ud, "- \n", "no null list item may be rendered") 68 assert.NotContains(t, ud, "- \n", "no null list item may be rendered")
68 // The rest of the default user-data is intact. 69 // The rest of the default user-data is intact.
69 assert.Contains(t, ud, "hostname: h") 70 assert.Contains(t, ud, "hostname: h")
70 assert.Contains(t, ud, "name: ubuntu") 71 assert.Contains(t, ud, "name: "+guest.LoginUser)
71 } 72 }
72 73
73 func TestUserDataIncludesSSHAuthorizedKeysWhenKeyPresent(t *testing.T) { 74 func TestUserDataIncludesSSHAuthorizedKeysWhenKeyPresent(t *testing.T) {
internal/arch/arch_test.go
Old New
@@ -133,8 +133,8 @@ func TestWirePlaneIsLeaf(t *testing.T) {
133 } 133 }
134 } 134 }
135 } 135 }
136 if swept < 8 { 136 if swept < 9 {
137 t.Errorf("wire-plane sweep saw only %d packages — classifier drift? (expect pb, transport, joinblob, cloudinit, names, random, version, relmanifest)", swept) 137 t.Errorf("wire-plane sweep saw only %d packages — classifier drift? (expect pb, transport, joinblob, cloudinit, guest, names, random, version, relmanifest)", swept)
138 } 138 }
139 } 139 }
140 140
internal/cli/mint.go
Old New
@@ -11,6 +11,7 @@ import (
11 "path/filepath" 11 "path/filepath"
12 "time" 12 "time"
13 13
14 "github.com/a73x/eitri/internal/guest"
14 "golang.org/x/crypto/ssh" 15 "golang.org/x/crypto/ssh"
15 "golang.org/x/term" 16 "golang.org/x/term"
16 ) 17 )
@@ -125,7 +126,7 @@ func MintCert(caPath, keyPath, keyID string) error {
125 Serial: serial, 126 Serial: serial,
126 CertType: ssh.UserCert, 127 CertType: ssh.UserCert,
127 KeyId: keyID, 128 KeyId: keyID,
128 ValidPrincipals: []string{"ubuntu"}, 129 ValidPrincipals: []string{guest.LoginUser},
129 ValidAfter: uint64(now.Add(-time.Minute).Unix()), 130 ValidAfter: uint64(now.Add(-time.Minute).Unix()),
130 ValidBefore: uint64(now.Add(30 * time.Minute).Unix()), 131 ValidBefore: uint64(now.Add(30 * time.Minute).Unix()),
131 Permissions: ssh.Permissions{Extensions: map[string]string{ 132 Permissions: ssh.Permissions{Extensions: map[string]string{
internal/cli/mint_test.go
Old New
@@ -10,6 +10,7 @@ import (
10 "testing" 10 "testing"
11 "time" 11 "time"
12 12
13 "github.com/a73x/eitri/internal/guest"
13 "golang.org/x/crypto/ssh" 14 "golang.org/x/crypto/ssh"
14 ) 15 )
15 16
@@ -84,7 +85,7 @@ func TestMintCertShape(t *testing.T) {
84 if cert.CertType != ssh.UserCert || cert.KeyId != "tester@box" { 85 if cert.CertType != ssh.UserCert || cert.KeyId != "tester@box" {
85 t.Errorf("type/keyid: %v %q", cert.CertType, cert.KeyId) 86 t.Errorf("type/keyid: %v %q", cert.CertType, cert.KeyId)
86 } 87 }
87 if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != "ubuntu" { 88 if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != guest.LoginUser {
88 t.Errorf("principals: %v", cert.ValidPrincipals) 89 t.Errorf("principals: %v", cert.ValidPrincipals)
89 } 90 }
90 now := time.Now().Unix() 91 now := time.Now().Unix()
@@ -101,7 +102,7 @@ func TestMintCertShape(t *testing.T) {
101 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(a))) == 102 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(a))) ==
102 strings.TrimSpace(string(ssh.MarshalAuthorizedKey(caPub))) 103 strings.TrimSpace(string(ssh.MarshalAuthorizedKey(caPub)))
103 }} 104 }}
104 if err := checker.CheckCert("ubuntu", cert); err != nil { 105 if err := checker.CheckCert(guest.LoginUser, cert); err != nil {
105 t.Errorf("cert not accepted by its own CA: %v", err) 106 t.Errorf("cert not accepted by its own CA: %v", err)
106 } 107 }
107 } 108 }
internal/cli/sshcmd.go
Old New
@@ -10,6 +10,7 @@ import (
10 "strings" 10 "strings"
11 "syscall" 11 "syscall"
12 12
13 "github.com/a73x/eitri/internal/guest"
13 "github.com/a73x/eitri/internal/server/api/client" 14 "github.com/a73x/eitri/internal/server/api/client"
14 ) 15 )
15 16
@@ -35,15 +36,15 @@ func SSHArgv(e Env, vm string, extra []string) []string {
35 gateHost, gatePort = e.Gate, "22" 36 gateHost, gatePort = e.Gate, "22"
36 } 37 }
37 proxy := fmt.Sprintf( 38 proxy := fmt.Sprintf(
38 "ssh -W %%h:%%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -i %s -p %s ubuntu@%s", 39 "ssh -W %%h:%%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -i %s -p %s %s@%s",
39 shq(e.KnownHosts), shq(e.Key), gatePort, gateHost) 40 shq(e.KnownHosts), shq(e.Key), gatePort, guest.LoginUser, gateHost)
40 argv := []string{ 41 argv := []string{
41 "ssh", 42 "ssh",
42 "-o", "ProxyCommand=" + proxy, 43 "-o", "ProxyCommand=" + proxy,
43 "-o", "StrictHostKeyChecking=yes", 44 "-o", "StrictHostKeyChecking=yes",
44 "-o", "UserKnownHostsFile=" + e.KnownHosts, 45 "-o", "UserKnownHostsFile=" + e.KnownHosts,
45 "-i", e.Key, 46 "-i", e.Key,
46 "ubuntu@" + e.Tenant + "." + vm, 47 guest.LoginUser + "@" + e.Tenant + "." + vm,
47 } 48 }
48 return append(argv, extra...) 49 return append(argv, extra...)
49 } 50 }
internal/gateclient/auth.go
Old New
@@ -38,10 +38,11 @@ type CertAuthority interface {
38 // key must be registered with the tenant (see Register) so VMs trust the 38 // key must be registered with the tenant (see Register) so VMs trust the
39 // certs this client signs. 39 // certs this client signs.
40 type GateAuth struct { 40 type GateAuth struct {
41 api CertAuthority 41 api CertAuthority
42 userCA ssh.Signer // this client's persistent user CA; signs user certs locally 42 userCA ssh.Signer // this client's persistent user CA; signs user certs locally
43 tenant string // this client's tenant; connect names are <tenant>.<vm> 43 tenant string // this client's tenant; connect names are <tenant>.<vm>
44 now func() time.Time 44 loginUser string // the guest account this authenticates as; also the cert principal
45 now func() time.Time
45 46
46 mu sync.Mutex 47 mu sync.Mutex
47 ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once 48 ephemeral ssh.Signer // ephemeral SSH keypair; generated lazily, once
@@ -52,16 +53,24 @@ type GateAuth struct {
52 } 53 }
53 54
54 // NewGateAuth constructs a GateAuth backed by api, self-signing user certs with 55 // NewGateAuth constructs a GateAuth backed by api, self-signing user certs with
55 // userCA and dialing VMs under tenant. If now is nil, time.Now is used. The 56 // userCA and dialing VMs under tenant as loginUser. loginUser is both the SSH
56 // ephemeral keypair and host CA key are NOT fetched here; both are established 57 // account the dialer logs in as (see Dial) and the sole principal the minted
57 // lazily on first use so construction cannot fail. 58 // cert carries, so the login user and the authorizing principal are one value
58 func NewGateAuth(api CertAuthority, userCA ssh.Signer, tenant string, now func() time.Time) *GateAuth { 59 // and cannot diverge. If now is nil, time.Now is used. The ephemeral keypair
60 // and host CA key are NOT fetched here; both are established lazily on first use
61 // so construction cannot fail.
62 func NewGateAuth(api CertAuthority, userCA ssh.Signer, tenant, loginUser string, now func() time.Time) *GateAuth {
59 if now == nil { 63 if now == nil {
60 now = time.Now 64 now = time.Now
61 } 65 }
62 return &GateAuth{api: api, userCA: userCA, tenant: tenant, now: now} 66 return &GateAuth{api: api, userCA: userCA, tenant: tenant, loginUser: loginUser, now: now}
63 } 67 }
64 68
69 // LoginUser returns the guest account this client authenticates as, which is
70 // also the principal its self-signed certs carry. Dial reads the SSH login user
71 // from here so it and the cert principal are guaranteed to match.
72 func (g *GateAuth) LoginUser() string { return g.loginUser }
73
65 // Register uploads this client's user-CA public key to its tenant so VMs trust 74 // Register uploads this client's user-CA public key to its tenant so VMs trust
66 // certs it signs. Idempotent; safe to call at startup before creating VMs. 75 // certs it signs. Idempotent; safe to call at startup before creating VMs.
67 func (g *GateAuth) Register(ctx context.Context) error { 76 func (g *GateAuth) Register(ctx context.Context) error {
@@ -122,8 +131,8 @@ func (g *GateAuth) signCertLocked() (*ssh.Certificate, error) {
122 Key: g.ephemeral.PublicKey(), 131 Key: g.ephemeral.PublicKey(),
123 Serial: serial, 132 Serial: serial,
124 CertType: ssh.UserCert, 133 CertType: ssh.UserCert,
125 KeyId: "ubuntu", 134 KeyId: g.loginUser,
126 ValidPrincipals: []string{"ubuntu"}, 135 ValidPrincipals: []string{g.loginUser},
127 ValidAfter: uint64(now.Add(-time.Minute).Unix()), // small skew backdate 136 ValidAfter: uint64(now.Add(-time.Minute).Unix()), // small skew backdate
128 ValidBefore: uint64(now.Add(30 * time.Minute).Unix()), 137 ValidBefore: uint64(now.Add(30 * time.Minute).Unix()),
129 Permissions: ssh.Permissions{Extensions: map[string]string{ 138 Permissions: ssh.Permissions{Extensions: map[string]string{
internal/gateclient/auth_test.go
Old New
@@ -78,9 +78,10 @@ func newTestSigner(t *testing.T) ssh.Signer {
78 } 78 }
79 79
80 // newTestGateAuth builds a GateAuth wired to fake, self-signing with userCA 80 // newTestGateAuth builds a GateAuth wired to fake, self-signing with userCA
81 // under tenant "default" and driven by clock (nil = time.Now). 81 // under tenant "default" as login user "ubuntu" and driven by clock (nil =
82 // time.Now).
82 func newTestGateAuth(fake *fakeCertAuthority, userCA ssh.Signer, clock func() time.Time) *GateAuth { 83 func newTestGateAuth(fake *fakeCertAuthority, userCA ssh.Signer, clock func() time.Time) *GateAuth {
83 return NewGateAuth(fake, userCA, "default", clock) 84 return NewGateAuth(fake, userCA, "default", "ubuntu", clock)
84 } 85 }
85 86
86 // certOf returns the *ssh.Certificate a cert-signer's public key carries. 87 // certOf returns the *ssh.Certificate a cert-signer's public key carries.
@@ -130,6 +131,26 @@ func TestGateAuthSignerSelfSignsOnceAndReuses(t *testing.T) {
130 "cert must be signed by our own user CA") 131 "cert must be signed by our own user CA")
131 } 132 }
132 133
134 // TestGateAuthCertPrincipalTracksLoginUser locks the anti-drift guarantee: the
135 // cert's principal (and KeyId) is exactly the login user GateAuth was built
136 // with, and LoginUser reports that same value — so a non-default guest account
137 // (here "debian") cannot end up logging in as one user while its only cert
138 // principal names another.
139 func TestGateAuthCertPrincipalTracksLoginUser(t *testing.T) {
140 fake := newFakeCertAuthority(t)
141 userCA := newTestSigner(t)
142 ga := NewGateAuth(fake, userCA, "default", "debian", nil)
143
144 s, err := ga.Signer(t.Context())
145 require.NoError(t, err)
146
147 cert := certOf(t, s)
148 assert.Equal(t, "debian", ga.LoginUser())
149 assert.Equal(t, []string{"debian"}, cert.ValidPrincipals,
150 "the cert principal must be the login user, never a hardcoded default")
151 assert.Equal(t, "debian", cert.KeyId)
152 }
153
133 func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) { 154 func TestGateAuthSignerRefreshesNearExpiry(t *testing.T) {
134 fake := newFakeCertAuthority(t) 155 fake := newFakeCertAuthority(t)
135 userCA := newTestSigner(t) 156 userCA := newTestSigner(t)
internal/gateclient/dial.go
Old New
@@ -17,13 +17,16 @@ type Credentials interface {
17 HostKeyCallback() ssh.HostKeyCallback 17 HostKeyCallback() ssh.HostKeyCallback
18 // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name. 18 // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name.
19 ConnectName(ctx context.Context, vmName string) (string, error) 19 ConnectName(ctx context.Context, vmName string) (string, error)
20 // LoginUser is the guest account to log in as; it is also the principal the
21 // signer's cert carries, so Dial takes the login user from here (not a
22 // separate field) and the two cannot disagree.
23 LoginUser() string
20 } 24 }
21 25
22 // DialConfig configures SSH access to VMs through the eitri SSH-CA jump gate. 26 // DialConfig configures SSH access to VMs through the eitri SSH-CA jump gate.
23 type DialConfig struct { 27 type DialConfig struct {
24 Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) 28 Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host)
25 VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) 29 Auth Credentials // minted user-cert signer + CA host verifier; also supplies the login user
26 Auth Credentials // minted user-cert signer + CA host verifier
27 } 30 }
28 31
29 // Dial reaches the VM named vmName through the eitri SSH-CA gate: a client 32 // Dial reaches the VM named vmName through the eitri SSH-CA gate: a client
@@ -49,9 +52,10 @@ func Dial(ctx context.Context, cfg DialConfig, vmName string) (*ssh.Client, erro
49 // Both hops share the same client config: the same CA-signed user cert 52 // Both hops share the same client config: the same CA-signed user cert
50 // authenticates to the gate and to the VM, and the same callback verifies 53 // authenticates to the gate and to the VM, and the same callback verifies
51 // both host certs against the eitri CA. The gate ignores the outer username, 54 // both host certs against the eitri CA. The gate ignores the outer username,
52 // so using the VM login user throughout is harmless. 55 // so using the VM login user throughout is harmless. The login user comes
56 // from the credentials, which is the same value the cert's principal carries.
53 clientConf := &ssh.ClientConfig{ 57 clientConf := &ssh.ClientConfig{
54 User: cfg.VMUser, 58 User: cfg.Auth.LoginUser(),
55 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, 59 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
56 HostKeyCallback: hostCB, 60 HostKeyCallback: hostCB,
57 Timeout: 15 * time.Second, 61 Timeout: 15 * time.Second,
internal/guest/guest.go
Old New
@@ -0,0 +1,14 @@
1 // Package guest holds the handful of facts the control plane and the data
2 // plane must agree on about a guest VM. It is a wire-plane leaf: it imports
3 // nothing, so either plane — and the CLI and the smoke harness — can depend on
4 // it without coupling the two binaries to each other.
5 package guest
6
7 // LoginUser is the account every guest is created with — the agent's cloud-init
8 // seed adds it — and therefore the principal every user certificate must carry:
9 // a guest trusts its tenant's CA set through a bare TrustedUserCAKeys line, so
10 // sshd matches the certificate principal against the login user. Stock Ubuntu
11 // cloud images name it "ubuntu". It is named here exactly once so no layer —
12 // the cert minter, the ssh command builder, the gate dialer, the smoke default
13 // — pins its own copy that could drift from what the guest actually provisions.
14 const LoginUser = "ubuntu"
internal/mcpserver/tools.go
Old New
@@ -135,7 +135,7 @@ type VMCreateIn struct {
135 Host string `json:"host,omitempty" jsonschema:"host name to place on; default first eligible host"` 135 Host string `json:"host,omitempty" jsonschema:"host name to place on; default first eligible host"`
136 VCPUs int64 `json:"vcpus,omitempty" jsonschema:"default 2"` 136 VCPUs int64 `json:"vcpus,omitempty" jsonschema:"default 2"`
137 MemMB int64 `json:"mem_mb,omitempty" jsonschema:"default 2048"` 137 MemMB int64 `json:"mem_mb,omitempty" jsonschema:"default 2048"`
138 DiskGB int64 `json:"disk_gb,omitempty" jsonschema:"default 20"` 138 DiskGB int64 `json:"disk_gb,omitempty" jsonschema:"default 10"`
139 CloudInit string `json:"cloud_init,omitempty" jsonschema:"optional user cloud-init"` 139 CloudInit string `json:"cloud_init,omitempty" jsonschema:"optional user cloud-init"`
140 Wait *bool `json:"wait,omitempty" jsonschema:"wait for ready+cloud-init (default true)"` 140 Wait *bool `json:"wait,omitempty" jsonschema:"wait for ready+cloud-init (default true)"`
141 } 141 }
@@ -162,15 +162,10 @@ func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error
162 if req.Name == "" { 162 if req.Name == "" {
163 req.Name = "claude-" + random.Hex(3) 163 req.Name = "claude-" + random.Hex(3)
164 } 164 }
165 if req.VCPUs == 0 { 165 // Sizes (vcpus, mem_mb, disk_gb) are deliberately left at zero when omitted:
166 req.VCPUs = 2 166 // the control plane owns those defaults (types.Default*), so a zero here
167 } 167 // inherits the one named value rather than pinning a second, drift-prone set
168 if req.MemMB == 0 { 168 // on this side.
169 req.MemMB = 2048
170 }
171 if req.DiskGB == 0 {
172 req.DiskGB = 20
173 }
174 if in.Host != "" { 169 if in.Host != "" {
175 hostID, err := t.resolveHost(ctx, in.Host) 170 hostID, err := t.resolveHost(ctx, in.Host)
176 if err != nil { 171 if err != nil {
internal/mcpserver/tools_test.go
Old New
@@ -205,9 +205,13 @@ func TestCreateWaitsForReadyAndCloudInit(t *testing.T) {
205 205
206 require.Len(t, api.created, 1) 206 require.Len(t, api.created, 1)
207 req := api.created[0] 207 req := api.created[0]
208 assert.Equal(t, int64(2), req.VCPUs) 208 // MCP pins no size defaults of its own: omitted vcpus/mem_mb/disk_gb reach
209 assert.Equal(t, int64(2048), req.MemMB) 209 // the control plane as zero and inherit its sole named defaults
210 assert.Equal(t, int64(20), req.DiskGB) 210 // (types.Default*, pinned in api_test.go), so the two layers cannot pin
211 // divergent values.
212 assert.Zero(t, req.VCPUs)
213 assert.Zero(t, req.MemMB)
214 assert.Zero(t, req.DiskGB)
211 assert.Equal(t, "h1", req.HostID) 215 assert.Equal(t, "h1", req.HostID)
212 assert.Empty(t, req.SSHAuthorizedKey, "no key injection under the CA model") 216 assert.Empty(t, req.SSHAuthorizedKey, "no key injection under the CA model")
213 217
internal/server/api/api.go
Old New
@@ -704,13 +704,13 @@ func (a *API) applyVMDefaults(req *types.CreateVMRequest, hostArch string) (stri
704 return "image_url and image_sha256 must be provided together", http.StatusBadRequest 704 return "image_url and image_sha256 must be provided together", http.StatusBadRequest
705 } 705 }
706 if req.VCPUs == 0 { 706 if req.VCPUs == 0 {
707 req.VCPUs = 2 707 req.VCPUs = types.DefaultVCPUs
708 } 708 }
709 if req.MemMB == 0 { 709 if req.MemMB == 0 {
710 req.MemMB = 2048 710 req.MemMB = types.DefaultMemMB
711 } 711 }
712 if req.DiskGB == 0 { 712 if req.DiskGB == 0 {
713 req.DiskGB = 10 713 req.DiskGB = types.DefaultDiskGB
714 } 714 }
715 if req.PowerState == "" { 715 if req.PowerState == "" {
716 req.PowerState = "running" 716 req.PowerState = "running"
internal/server/api/api_test.go
Old New
@@ -12,6 +12,7 @@ import (
12 "time" 12 "time"
13 13
14 "github.com/a73x/eitri/internal/joinblob" 14 "github.com/a73x/eitri/internal/joinblob"
15 "github.com/a73x/eitri/internal/server/api/types"
15 "github.com/a73x/eitri/internal/server/hub" 16 "github.com/a73x/eitri/internal/server/hub"
16 "github.com/a73x/eitri/internal/server/registry" 17 "github.com/a73x/eitri/internal/server/registry"
17 "github.com/a73x/eitri/internal/server/release" 18 "github.com/a73x/eitri/internal/server/release"
@@ -505,9 +506,9 @@ func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) {
505 506
506 vms, _ := st.ListVMs() 507 vms, _ := st.ListVMs()
507 require.Len(t, vms, 1) 508 require.Len(t, vms, 1)
508 assert.Equal(t, int64(2), vms[0].VCPUs) 509 assert.Equal(t, types.DefaultVCPUs, vms[0].VCPUs)
509 assert.Equal(t, int64(2048), vms[0].MemMB) 510 assert.Equal(t, types.DefaultMemMB, vms[0].MemMB)
510 assert.Equal(t, int64(10), vms[0].DiskGB) 511 assert.Equal(t, types.DefaultDiskGB, vms[0].DiskGB)
511 assert.Equal(t, "running", vms[0].PowerState) 512 assert.Equal(t, "running", vms[0].PowerState)
512 assert.NotEmpty(t, vms[0].Name) 513 assert.NotEmpty(t, vms[0].Name)
513 assert.Contains(t, vms[0].ImageURL, "ubuntu") 514 assert.Contains(t, vms[0].ImageURL, "ubuntu")
internal/server/api/types/types.go
Old New
@@ -256,6 +256,16 @@ type CreateVMRequest struct {
256 DiskGB int64 `json:"disk_gb"` 256 DiskGB int64 `json:"disk_gb"`
257 } 257 }
258 258
259 // Default VM sizes the control plane applies when a create request leaves a
260 // field at zero. They live beside CreateVMRequest so the API's defaulting and
261 // every other surface (the console form, the docs) name one value instead of a
262 // private literal — nothing pins its own default.
263 const (
264 DefaultVCPUs int64 = 2
265 DefaultMemMB int64 = 2048
266 DefaultDiskGB int64 = 10
267 )
268
259 // PatchVMRequest is the PATCH /api/v1/vms/{id} body: the desired power state, 269 // PatchVMRequest is the PATCH /api/v1/vms/{id} body: the desired power state,
260 // "running" or "stopped". 270 // "running" or "stopped".
261 type PatchVMRequest struct { 271 type PatchVMRequest struct {
internal/server/api/types/types_defaults_test.go
Old New
@@ -0,0 +1,70 @@
1 package types_test
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10
11 "github.com/a73x/eitri/internal/server/api/types"
12 )
13
14 // TestNonGoSurfacesMatchVMDefaults is the cross-language tripwire for the VM
15 // create defaults. Go callers read types.Default* directly, but the console
16 // form and the quickstart prose restate the same numbers in a language that
17 // cannot import a Go constant. This test reads those files and fails if they
18 // drift from the constants, so a change to a default cannot silently leave the
19 // UI or the docs advertising the old value.
20 func TestNonGoSurfacesMatchVMDefaults(t *testing.T) {
21 root := repoRoot(t)
22
23 page := readRepoFile(t, root, "web/src/routes/+page.svelte")
24 for _, want := range []string{
25 fmt.Sprintf(`placeholder="%d"`, types.DefaultVCPUs),
26 fmt.Sprintf(`placeholder="%d"`, types.DefaultMemMB),
27 fmt.Sprintf(`placeholder="%d"`, types.DefaultDiskGB),
28 } {
29 if !strings.Contains(page, want) {
30 t.Errorf("console create form (+page.svelte) is missing %q — its placeholder drifted from the types.Default* constant", want)
31 }
32 }
33
34 quickstart := readRepoFile(t, root, "docs/quickstart.md")
35 wantLine := fmt.Sprintf("%d vCPUs, %d MB, %d GB", types.DefaultVCPUs, types.DefaultMemMB, types.DefaultDiskGB)
36 if !strings.Contains(quickstart, wantLine) {
37 t.Errorf("docs/quickstart.md is missing %q — the documented defaults drifted from the types.Default* constants", wantLine)
38 }
39 }
40
41 // repoRoot walks up from this test file (located via runtime.Caller, so the
42 // result is independent of the test's working directory) until it finds the
43 // go.mod that marks the module root.
44 func repoRoot(t *testing.T) string {
45 t.Helper()
46 _, file, _, ok := runtime.Caller(0)
47 if !ok {
48 t.Fatal("runtime.Caller failed; cannot locate the repo root")
49 }
50 dir := filepath.Dir(file)
51 for {
52 if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
53 return dir
54 }
55 parent := filepath.Dir(dir)
56 if parent == dir {
57 t.Fatal("walked to the filesystem root without finding go.mod")
58 }
59 dir = parent
60 }
61 }
62
63 func readRepoFile(t *testing.T, root, rel string) string {
64 t.Helper()
65 b, err := os.ReadFile(filepath.Join(root, rel))
66 if err != nil {
67 t.Fatalf("reading %s: %v", rel, err)
68 }
69 return string(b)
70 }
internal/server/boot/boot.go
Old New
@@ -20,6 +20,7 @@ import (
20 "time" 20 "time"
21 21
22 "github.com/a73x/eitri/internal/covsnap" 22 "github.com/a73x/eitri/internal/covsnap"
23 "github.com/a73x/eitri/internal/guest"
23 "github.com/a73x/eitri/internal/joinblob" 24 "github.com/a73x/eitri/internal/joinblob"
24 "github.com/a73x/eitri/internal/server/api" 25 "github.com/a73x/eitri/internal/server/api"
25 serverconfig "github.com/a73x/eitri/internal/server/config" 26 serverconfig "github.com/a73x/eitri/internal/server/config"
@@ -194,7 +195,7 @@ func run(cfgPath string) error {
194 // actually using it. Wired to the API only when there is a CA to verify 195 // actually using it. Wired to the API only when there is a CA to verify
195 // certificates against — with the gate off the routes answer 503, which is 196 // certificates against — with the gate off the routes answer 503, which is
196 // the same condition that makes remote exec refuse. 197 // the same condition that makes remote exec refuse.
197 keyring := delegation.New(time.Now, guestLoginUser) 198 keyring := delegation.New(time.Now, guest.LoginUser)
198 if sshGate != nil { 199 if sshGate != nil {
199 a.SetDelegations(keyring) 200 a.SetDelegations(keyring)
200 } 201 }
@@ -245,7 +246,7 @@ func run(cfgPath string) error {
245 Lookup: vmLookup(st), 246 Lookup: vmLookup(st),
246 HostCA: sshGate.hostCAPublicKey(), 247 HostCA: sshGate.hostCAPublicKey(),
247 Gate: cfg.SSHGateDomain, 248 Gate: cfg.SSHGateDomain,
248 VMUser: guestLoginUser, 249 VMUser: guest.LoginUser,
249 // The API's own origin, so a refusal names something dialable rather 250 // The API's own origin, so a refusal names something dialable rather
250 // than a path on whichever host the caller happens to be talking to. 251 // than a path on whichever host the caller happens to be talking to.
251 DelegationsURL: a.URL("/api/v1/delegations"), 252 DelegationsURL: a.URL("/api/v1/delegations"),
@@ -350,12 +351,6 @@ func sweepDelegations(ctx context.Context, k *delegation.Keyring) {
350 } 351 }
351 } 352 }
352 353
353 // guestLoginUser is the account a guest is logged into, and therefore the
354 // principal every user certificate carries: a guest trusts its tenant's CA set
355 // through a bare TrustedUserCAKeys line, so sshd matches the certificate
356 // principal against the login user. Stock cloud images name it "ubuntu".
357 const guestLoginUser = "ubuntu"
358
359 // defaultImages translates the config's per-architecture guest images into the 354 // defaultImages translates the config's per-architecture guest images into the
360 // API's own type, so the API package does not import the config schema (R1: the 355 // API's own type, so the API package does not import the config schema (R1: the
361 // wiring converts, the leaves stay independent). 356 // wiring converts, the leaves stay independent).
internal/shape/classify.go
Old New
@@ -33,6 +33,7 @@ func classify(rel string) Plane {
33 strings.HasPrefix(rel, "internal/transport"), 33 strings.HasPrefix(rel, "internal/transport"),
34 strings.HasPrefix(rel, "internal/joinblob"), 34 strings.HasPrefix(rel, "internal/joinblob"),
35 strings.HasPrefix(rel, "internal/cloudinit"), 35 strings.HasPrefix(rel, "internal/cloudinit"),
36 strings.HasPrefix(rel, "internal/guest"),
36 strings.HasPrefix(rel, "internal/names"), 37 strings.HasPrefix(rel, "internal/names"),
37 strings.HasPrefix(rel, "internal/random"), 38 strings.HasPrefix(rel, "internal/random"),
38 strings.HasPrefix(rel, "internal/relmanifest"), 39 strings.HasPrefix(rel, "internal/relmanifest"),
internal/shape/classify_test.go
Old New
@@ -27,6 +27,7 @@ func TestClassifyAssignsPlaneByPrefix(t *testing.T) {
27 "internal/agent/exec": PlaneData, 27 "internal/agent/exec": PlaneData,
28 "internal/pb": PlaneWire, 28 "internal/pb": PlaneWire,
29 "internal/transport": PlaneWire, 29 "internal/transport": PlaneWire,
30 "internal/guest": PlaneWire,
30 "internal/names": PlaneWire, 31 "internal/names": PlaneWire,
31 "internal/random": PlaneWire, 32 "internal/random": PlaneWire,
32 "cmd/eitri-server": PlaneBinaries, 33 "cmd/eitri-server": PlaneBinaries,
internal/smoke/config.go
Old New
@@ -4,6 +4,8 @@ import (
4 "fmt" 4 "fmt"
5 "strconv" 5 "strconv"
6 "strings" 6 "strings"
7
8 "github.com/a73x/eitri/internal/guest"
7 ) 9 )
8 10
9 // Config holds the environment-sourced settings for one smoke run. It mirrors 11 // Config holds the environment-sourced settings for one smoke run. It mirrors
@@ -47,7 +49,7 @@ type Config struct {
47 // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the 49 // SSH-CA gate check. When SmokeGate and SmokeUserCAFile are both set, the
48 // scenario proves guest access through the gate (a hard gate). Optional. 50 // scenario proves guest access through the gate (a hard gate). Optional.
49 SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>" 51 SmokeGate string // SMOKE_GATE, "<gate-domain>:<port>"
50 SmokeVMUser string // SMOKE_VM_USER, default "ubuntu" 52 SmokeVMUser string // SMOKE_VM_USER, default guest.LoginUser
51 SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key 53 SmokeUserCAFile string // SMOKE_USER_CA_FILE, load-or-create user CA key
52 } 54 }
53 55
@@ -96,7 +98,7 @@ func loadConfig(getenv func(string) string) (Config, error) {
96 98
97 smokeVMUser := getenv("SMOKE_VM_USER") 99 smokeVMUser := getenv("SMOKE_VM_USER")
98 if smokeVMUser == "" { 100 if smokeVMUser == "" {
99 smokeVMUser = "ubuntu" 101 smokeVMUser = guest.LoginUser
100 } 102 }
101 103
102 mcpURLs := strings.Fields(getenv("SMOKE_MCP_URL")) 104 mcpURLs := strings.Fields(getenv("SMOKE_MCP_URL"))
internal/smoke/config_test.go
Old New
@@ -3,6 +3,8 @@ package smoke
3 import ( 3 import (
4 "strings" 4 "strings"
5 "testing" 5 "testing"
6
7 "github.com/a73x/eitri/internal/guest"
6 ) 8 )
7 9
8 // fakeGetenv returns a getenv func backed by a map, so tests never touch the 10 // fakeGetenv returns a getenv func backed by a map, so tests never touch the
@@ -206,8 +208,8 @@ func TestLoadConfigSmokeGateDefaults(t *testing.T) {
206 if cfg.SmokeGate != "" { 208 if cfg.SmokeGate != "" {
207 t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate) 209 t.Errorf("SmokeGate = %q, want empty", cfg.SmokeGate)
208 } 210 }
209 if cfg.SmokeVMUser != "ubuntu" { 211 if cfg.SmokeVMUser != guest.LoginUser {
210 t.Errorf("SmokeVMUser = %q, want ubuntu", cfg.SmokeVMUser) 212 t.Errorf("SmokeVMUser = %q, want %q", cfg.SmokeVMUser, guest.LoginUser)
211 } 213 }
212 if cfg.SmokeUserCAFile != "" { 214 if cfg.SmokeUserCAFile != "" {
213 t.Errorf("SmokeUserCAFile = %q, want empty", cfg.SmokeUserCAFile) 215 t.Errorf("SmokeUserCAFile = %q, want empty", cfg.SmokeUserCAFile)
internal/smoke/gatecheck.go
Old New
@@ -16,7 +16,7 @@ import (
16 // tenant is the operator PAT's own tenant (derived via Me() by the caller), so 16 // tenant is the operator PAT's own tenant (derived via Me() by the caller), so
17 // the CA registration and connect names match the fleet's real partition. 17 // the CA registration and connect names match the fleet's real partition.
18 func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks { 18 func realGateHooks(cfg Config, tenant string, ca gateclient.CertAuthority, userCA ssh.Signer, now func() time.Time, sleep func(time.Duration)) *gateHooks {
19 auth := gateclient.NewGateAuth(ca, userCA, tenant, now) 19 auth := gateclient.NewGateAuth(ca, userCA, tenant, cfg.SmokeVMUser, now)
20 return &gateHooks{ 20 return &gateHooks{
21 register: func(ctx context.Context) error { return auth.Register(ctx) }, 21 register: func(ctx context.Context) error { return auth.Register(ctx) },
22 exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) }, 22 exec: func(ctx context.Context, vmName string) error { return gateExec(ctx, cfg, auth, vmName, now, sleep) },
@@ -31,9 +31,8 @@ func gateExec(ctx context.Context, cfg Config, auth gateclient.Credentials, vmNa
31 var lastErr error 31 var lastErr error
32 err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) { 32 err := pollLoop(ctx, now, sleep, 120*time.Second, 5*time.Second, func() (bool, error) {
33 client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{ 33 client, dialErr := gateclient.Dial(ctx, gateclient.DialConfig{
34 Gate: cfg.SmokeGate, 34 Gate: cfg.SmokeGate,
35 VMUser: cfg.SmokeVMUser, 35 Auth: auth,
36 Auth: auth,
37 }, vmName) 36 }, vmName)
38 if dialErr != nil { 37 if dialErr != nil {
39 lastErr = dialErr 38 lastErr = dialErr
web/src/routes/+page.svelte
Old New
@@ -388,6 +388,9 @@
388 </label> 388 </label>
389 389
390 {#if advanced} 390 {#if advanced}
391 <!-- Placeholders mirror the control plane's create defaults
392 (types.DefaultVCPUs / DefaultMemMB / DefaultDiskGB); a Go guard
393 test (types_defaults_test.go) fails if they drift apart. -->
391 <div class="grid"> 394 <div class="grid">
392 <label>vCPUs<input type="number" bind:value={form.vcpus} placeholder="2" /></label> 395 <label>vCPUs<input type="number" bind:value={form.vcpus} placeholder="2" /></label>
393 <label>Mem MB<input type="number" bind:value={form.mem_mb} placeholder="2048" /></label> 396 <label>Mem MB<input type="number" bind:value={form.mem_mb} placeholder="2048" /></label>