diff --git a/contract/machines.go b/contract/machines.go index aec0ce0..284b6c4 100644 --- a/contract/machines.go +++ b/contract/machines.go @@ -17,7 +17,7 @@ type Machine struct { } type GuestConfig struct { - Hostname string `json:"hostname,omitempty"` + Hostname string `json:"hostname,omitempty"` AuthorizedKeys []string `json:"authorized_keys,omitempty"` TrustedUserCAKeys []string `json:"trusted_user_ca_keys,omitempty"` LoginWebhook *GuestLoginWebhook `json:"login_webhook,omitempty"` diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 45bdb00..afcb343 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -41,7 +41,7 @@ type Daemon struct { store store.Store runtime Runtime - reconfigureGuestIdentity func(context.Context, string, contracthost.MachineID) error + reconfigureGuestIdentity func(context.Context, string, contracthost.MachineID, *contracthost.GuestConfig) error readGuestSSHPublicKey func(context.Context, string) (string, error) syncGuestFilesystem func(context.Context, string) error diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 05b329e..8743306 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -128,6 +128,7 @@ func TestCreateMachineStagesArtifactsAndPersistsState(t *testing.T) { RootFSURL: server.URL + "/rootfs", }, GuestConfig: &contracthost.GuestConfig{ + Hostname: "workbox", AuthorizedKeys: []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestOverrideKey daemon-test", }, @@ -179,7 +180,7 @@ func TestCreateMachineStagesArtifactsAndPersistsState(t *testing.T) { if !ok { t.Fatalf("mmds payload type mismatch: got %T", runtime.lastSpec.MMDS.Data) } - if payload.Latest.MetaData.Hostname != "vm-1" { + if payload.Latest.MetaData.Hostname != "workbox" { t.Fatalf("mmds hostname mismatch: got %q", payload.Latest.MetaData.Hostname) } authorizedKeys := strings.Join(payload.Latest.MetaData.AuthorizedKeys, "\n") @@ -340,7 +341,7 @@ func TestRestoreSnapshotFallsBackToLocalSnapshotNetwork(t *testing.T) { t.Fatalf("create daemon: %v", err) } stubGuestSSHPublicKeyReader(hostDaemon) - hostDaemon.reconfigureGuestIdentity = func(context.Context, string, contracthost.MachineID) error { return nil } + hostDaemon.reconfigureGuestIdentity = func(context.Context, string, contracthost.MachineID, *contracthost.GuestConfig) error { return nil } artifactRef := contracthost.ArtifactRef{KernelImageURL: "kernel", RootFSURL: "rootfs"} kernelPath := filepath.Join(root, "artifact-kernel") @@ -397,6 +398,7 @@ func TestRestoreSnapshotFallsBackToLocalSnapshotNetwork(t *testing.T) { {ID: "disk-system", Kind: contracthost.SnapshotArtifactKindDisk, Name: "system.img", DownloadURL: server.URL + "/system"}, }, }, + GuestConfig: &contracthost.GuestConfig{Hostname: "restored-shell"}, }) if err != nil { t.Fatalf("restore snapshot: %v", err) @@ -462,9 +464,11 @@ func TestRestoreSnapshotUsesDurableSnapshotSpec(t *testing.T) { stubGuestSSHPublicKeyReader(hostDaemon) var reconfiguredHost string var reconfiguredMachine contracthost.MachineID - hostDaemon.reconfigureGuestIdentity = func(_ context.Context, host string, machineID contracthost.MachineID) error { + var reconfiguredConfig *contracthost.GuestConfig + hostDaemon.reconfigureGuestIdentity = func(_ context.Context, host string, machineID contracthost.MachineID, guestConfig *contracthost.GuestConfig) error { reconfiguredHost = host reconfiguredMachine = machineID + reconfiguredConfig = cloneGuestConfig(guestConfig) return nil } @@ -497,6 +501,7 @@ func TestRestoreSnapshotUsesDurableSnapshotSpec(t *testing.T) { {ID: "disk-user-0", Kind: contracthost.SnapshotArtifactKindDisk, Name: "user-0.img", DownloadURL: server.URL + "/user-0"}, }, }, + GuestConfig: &contracthost.GuestConfig{Hostname: "restored-shell"}, }) if err != nil { t.Fatalf("restore snapshot: %v", err) @@ -525,6 +530,9 @@ func TestRestoreSnapshotUsesDurableSnapshotSpec(t *testing.T) { if reconfiguredHost != "127.0.0.1" || reconfiguredMachine != "restored" { t.Fatalf("guest identity reconfigure mismatch: host=%q machine=%q", reconfiguredHost, reconfiguredMachine) } + if reconfiguredConfig == nil || reconfiguredConfig.Hostname != "restored-shell" { + t.Fatalf("guest identity hostname mismatch: %#v", reconfiguredConfig) + } machine, err := fileStore.GetMachine(context.Background(), "restored") if err != nil { diff --git a/internal/daemon/files.go b/internal/daemon/files.go index 4adfac6..54930a4 100644 --- a/internal/daemon/files.go +++ b/internal/daemon/files.go @@ -251,9 +251,13 @@ func (d *Daemon) mergedGuestConfig(config *contracthost.GuestConfig) (*contracth } merged := &contracthost.GuestConfig{ + Hostname: "", AuthorizedKeys: authorizedKeys, TrustedUserCAKeys: nil, } + if config != nil { + merged.Hostname = strings.TrimSpace(config.Hostname) + } if strings.TrimSpace(d.config.GuestLoginCAPublicKey) != "" { merged.TrustedUserCAKeys = append(merged.TrustedUserCAKeys, d.config.GuestLoginCAPublicKey) } @@ -447,6 +451,21 @@ func validateGuestConfig(config *contracthost.GuestConfig) error { if config == nil { return nil } + if config.Hostname != "" { + hostname := strings.TrimSpace(config.Hostname) + if hostname == "" { + return fmt.Errorf("guest_config.hostname is required") + } + if len(hostname) > 63 { + return fmt.Errorf("guest_config.hostname must be 63 characters or fewer") + } + if strings.ContainsAny(hostname, "/\\") { + return fmt.Errorf("guest_config.hostname must not contain path separators") + } + if strings.ContainsAny(hostname, " \t\r\n") { + return fmt.Errorf("guest_config.hostname must not contain whitespace") + } + } for i, key := range config.AuthorizedKeys { if strings.TrimSpace(key) == "" { return fmt.Errorf("guest_config.authorized_keys[%d] is required", i) diff --git a/internal/daemon/guest_identity.go b/internal/daemon/guest_identity.go index 59d98b1..e3a7721 100644 --- a/internal/daemon/guest_identity.go +++ b/internal/daemon/guest_identity.go @@ -10,9 +10,9 @@ import ( contracthost "github.com/getcompanion-ai/computer-host/contract" ) -func (d *Daemon) reconfigureGuestIdentityOverSSH(ctx context.Context, runtimeHost string, machineID contracthost.MachineID) error { +func (d *Daemon) reconfigureGuestIdentityOverSSH(ctx context.Context, runtimeHost string, machineID contracthost.MachineID, guestConfig *contracthost.GuestConfig) error { runtimeHost = strings.TrimSpace(runtimeHost) - machineName := strings.TrimSpace(string(machineID)) + machineName := guestHostname(machineID, guestConfig) if runtimeHost == "" { return fmt.Errorf("guest runtime host is required") } diff --git a/internal/daemon/guest_metadata.go b/internal/daemon/guest_metadata.go index 08c4ad3..2df5a9a 100644 --- a/internal/daemon/guest_metadata.go +++ b/internal/daemon/guest_metadata.go @@ -35,6 +35,7 @@ func cloneGuestConfig(config *contracthost.GuestConfig) *contracthost.GuestConfi return nil } cloned := &contracthost.GuestConfig{ + Hostname: config.Hostname, AuthorizedKeys: append([]string(nil), config.AuthorizedKeys...), TrustedUserCAKeys: append([]string(nil), config.TrustedUserCAKeys...), } @@ -45,8 +46,17 @@ func cloneGuestConfig(config *contracthost.GuestConfig) *contracthost.GuestConfi return cloned } +func guestHostname(machineID contracthost.MachineID, guestConfig *contracthost.GuestConfig) string { + if guestConfig != nil { + if hostname := strings.TrimSpace(guestConfig.Hostname); hostname != "" { + return hostname + } + } + return strings.TrimSpace(string(machineID)) +} + func (d *Daemon) guestMetadataSpec(machineID contracthost.MachineID, guestConfig *contracthost.GuestConfig) (*firecracker.MMDSSpec, error) { - name := strings.TrimSpace(string(machineID)) + name := guestHostname(machineID, guestConfig) if name == "" { return nil, fmt.Errorf("machine id is required") } diff --git a/internal/daemon/review_regressions_test.go b/internal/daemon/review_regressions_test.go index f297533..2d3613f 100644 --- a/internal/daemon/review_regressions_test.go +++ b/internal/daemon/review_regressions_test.go @@ -448,7 +448,7 @@ func TestRestoreSnapshotDeletesSystemVolumeRecordWhenRelayAllocationFails(t *tes t.Fatalf("create daemon: %v", err) } stubGuestSSHPublicKeyReader(hostDaemon) - hostDaemon.reconfigureGuestIdentity = func(context.Context, string, contracthost.MachineID) error { return nil } + hostDaemon.reconfigureGuestIdentity = func(context.Context, string, contracthost.MachineID, *contracthost.GuestConfig) error { return nil } artifactRef := contracthost.ArtifactRef{KernelImageURL: "kernel", RootFSURL: "rootfs"} kernelPath := filepath.Join(root, "artifact-kernel") @@ -695,7 +695,7 @@ func TestRestoreSnapshotCleansStagingArtifactsAfterSuccess(t *testing.T) { t.Fatalf("create daemon: %v", err) } stubGuestSSHPublicKeyReader(hostDaemon) - hostDaemon.reconfigureGuestIdentity = func(context.Context, string, contracthost.MachineID) error { return nil } + hostDaemon.reconfigureGuestIdentity = func(context.Context, string, contracthost.MachineID, *contracthost.GuestConfig) error { return nil } server := newRestoreArtifactServer(t, map[string][]byte{ "/kernel": []byte("kernel"), diff --git a/internal/daemon/snapshot.go b/internal/daemon/snapshot.go index bb77800..93188ff 100644 --- a/internal/daemon/snapshot.go +++ b/internal/daemon/snapshot.go @@ -222,6 +222,13 @@ func (d *Daemon) RestoreSnapshot(ctx context.Context, snapshotID contracthost.Sn if err := validateArtifactRef(req.Artifact); err != nil { return nil, err } + if err := validateGuestConfig(req.GuestConfig); err != nil { + return nil, err + } + guestConfig, err := d.mergedGuestConfig(req.GuestConfig) + if err != nil { + return nil, err + } unlock := d.lockMachine(req.MachineID) defer unlock() @@ -349,7 +356,7 @@ func (d *Daemon) RestoreSnapshot(ctx context.Context, snapshotID contracthost.Sn clearOperation = true return nil, fmt.Errorf("wait for restored guest ready: %w", err) } - if err := d.reconfigureGuestIdentity(ctx, machineState.RuntimeHost, req.MachineID); err != nil { + if err := d.reconfigureGuestIdentity(ctx, machineState.RuntimeHost, req.MachineID, guestConfig); err != nil { _ = d.runtime.Delete(ctx, *machineState) _ = os.RemoveAll(filepath.Dir(newSystemDiskPath)) clearOperation = true @@ -406,6 +413,7 @@ func (d *Daemon) RestoreSnapshot(ctx context.Context, snapshotID contracthost.Sn machineRecord := model.MachineRecord{ ID: req.MachineID, Artifact: req.Artifact, + GuestConfig: cloneGuestConfig(guestConfig), SystemVolumeID: systemVolumeID, UserVolumeIDs: restoredUserVolumeIDs, RuntimeHost: machineState.RuntimeHost,