120a24fd
fix(consolidation): centralize the defaults the review found drifted
a73x 2026-08-12 19:12
Commit message
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> |