Third phase of splitting the daemon god-struct. WorkspaceService now
owns workspace.prepare / workspace.export plus the ssh-key +
git-identity + arbitrary-file sync that runs as part of VM start's
prepare_work_disk capability hook. workspaceLocks (the per-VM tar
serialisation set) lives on the service.
workspace.go and vm_authsync.go flipped receivers from *Daemon to
*WorkspaceService. The workspaceInspectRepo / workspaceImport test
seams moved onto the service as fields.
Peer-service dependencies go through narrow function-typed fields:
vmResolver, aliveChecker, waitGuestSSH, dialGuest, imageResolver,
imageWorkSeed, withVMLockByRef, beginOperation. WorkspaceService
never touches VMService / HostNetwork / ImageService directly —
only the exact operations the Daemon hands it at construction.
Daemon lazy-init helper workspaceSvc() mirrors the Phase 1/2
pattern. Test literals still write `&Daemon{store: db, runner: r}`
and get a wired workspace service for free. Tests that override the
inspect/import seams (workspace_test.go, ~4 sites) assign them on
d.workspaceSvc() instead of on the daemon literal.
Dispatch in daemon.go: vm.workspace.prepare and vm.workspace.export
now forward one-liners to d.workspaceSvc().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
406 lines
12 KiB
Go
406 lines
12 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"banger/internal/guest"
|
|
"banger/internal/model"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
const (
|
|
workDiskGitConfigRelativePath = ".gitconfig"
|
|
hostGlobalGitIdentitySource = "git config --global"
|
|
)
|
|
|
|
type gitIdentity struct {
|
|
Name string
|
|
Email string
|
|
}
|
|
|
|
func (s *WorkspaceService) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image, prep workDiskPreparation) error {
|
|
fingerprint, err := guest.AuthorizedPublicKeyFingerprint(s.config.SSHKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("derive authorized ssh key fingerprint: %w", err)
|
|
}
|
|
if prep.ClonedFromSeed && image.SeededSSHPublicKeyFingerprint != "" && image.SeededSSHPublicKeyFingerprint == fingerprint {
|
|
vmCreateStage(ctx, "prepare_work_disk", "using seeded SSH access")
|
|
return nil
|
|
}
|
|
publicKey, err := guest.AuthorizedPublicKey(s.config.SSHKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("derive authorized ssh key: %w", err)
|
|
}
|
|
vmCreateStage(ctx, "prepare_work_disk", "provisioning SSH access on work disk")
|
|
workMount, cleanupWork, err := system.MountTempDir(ctx, s.runner, vm.Runtime.WorkDiskPath, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanupWork()
|
|
|
|
if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Normalise the work-disk filesystem root: inside the guest this
|
|
// mounts at /root, which sshd inspects when StrictModes is on (the
|
|
// default after the hardening drop-in). Any drift — owner != root,
|
|
// group/other-writable — would make sshd silently reject the key.
|
|
if err := normaliseHomeDirPerms(ctx, s.runner, workMount); err != nil {
|
|
return err
|
|
}
|
|
|
|
sshDir := filepath.Join(workMount, ".ssh")
|
|
if _, err := s.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.runner.RunSudo(ctx, "chown", "0:0", sshDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
authorizedKeysPath := filepath.Join(sshDir, "authorized_keys")
|
|
existing, err := s.runner.RunSudo(ctx, "cat", authorizedKeysPath)
|
|
if err != nil {
|
|
existing = nil
|
|
}
|
|
merged := mergeAuthorizedKey(existing, publicKey)
|
|
|
|
tmpFile, err := os.CreateTemp("", "banger-authorized-keys-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
if _, err := tmpFile.Write(merged); err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := s.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil {
|
|
return err
|
|
}
|
|
if prep.ClonedFromSeed && image.Managed {
|
|
vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed")
|
|
if err := s.imageWorkSeed(ctx, image, fingerprint); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normaliseHomeDirPerms forces the home-directory mount point to
|
|
// 0755 root:root. sshd's StrictModes (the default, re-enabled after
|
|
// banger stopped shipping "StrictModes no") rejects authorized_keys
|
|
// if the user's HOME — here the work-disk filesystem root — is
|
|
// group/other-writable or owned by anyone other than root. mkfs.ext4
|
|
// normally creates an ext4 root dir at 0755 root:root, but older
|
|
// work-seed images may have drifted, and `cp -a` on a non-standard
|
|
// source can carry weird bits forward. Forcing a known-good state
|
|
// here is cheap insurance.
|
|
func normaliseHomeDirPerms(ctx context.Context, runner system.CommandRunner, workMount string) error {
|
|
if _, err := runner.RunSudo(ctx, "chown", "0:0", workMount); err != nil {
|
|
return err
|
|
}
|
|
if _, err := runner.RunSudo(ctx, "chmod", "0755", workMount); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
|
runner := s.runner
|
|
if runner == nil {
|
|
runner = system.NewRunner()
|
|
}
|
|
|
|
identity, err := resolveHostGlobalGitIdentity(ctx, runner)
|
|
if err != nil {
|
|
s.warnGitIdentitySyncSkipped(*vm, hostGlobalGitIdentitySource, err)
|
|
return nil
|
|
}
|
|
|
|
vmCreateStage(ctx, "prepare_work_disk", "syncing git identity")
|
|
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanupWork()
|
|
|
|
if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity)
|
|
}
|
|
|
|
// runFileSync applies every [[file_sync]] entry from the daemon config
|
|
// to the VM's work disk. Missing host paths are skipped with a warn.
|
|
// Other errors abort the VM create (since the user explicitly asked
|
|
// for the sync).
|
|
//
|
|
// File entries: `install -o 0 -g 0 -m <mode>` (mode defaults to 0600).
|
|
// Directory entries: walked in Go — each file is installed with its
|
|
// source permissions, each subdir is mkdir'd. The entry's `mode`
|
|
// field is only honoured for file entries.
|
|
func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) error {
|
|
if len(s.config.FileSync) == 0 {
|
|
return nil
|
|
}
|
|
|
|
runner := s.runner
|
|
if runner == nil {
|
|
runner = system.NewRunner()
|
|
}
|
|
|
|
hostHome, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve host user home: %w", err)
|
|
}
|
|
|
|
// Mount the work disk once and reuse for every entry.
|
|
var workMount string
|
|
var cleanupWork func() error
|
|
ensureMount := func() (string, error) {
|
|
if workMount != "" {
|
|
return workMount, nil
|
|
}
|
|
m, c, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
workMount = m
|
|
cleanupWork = c
|
|
if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil {
|
|
return "", err
|
|
}
|
|
return workMount, nil
|
|
}
|
|
defer func() {
|
|
if cleanupWork != nil {
|
|
cleanupWork()
|
|
}
|
|
}()
|
|
|
|
for _, entry := range s.config.FileSync {
|
|
hostPath := expandHostPath(entry.Host, hostHome)
|
|
guestRel := guestPathRelativeToRoot(entry.Guest)
|
|
|
|
info, err := os.Stat(hostPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
s.warnFileSyncSkipped(*vm, hostPath, err)
|
|
continue
|
|
}
|
|
return fmt.Errorf("file_sync: stat %s: %w", hostPath, err)
|
|
}
|
|
|
|
mount, err := ensureMount()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest)
|
|
|
|
target := filepath.Join(mount, guestRel)
|
|
if _, err := runner.RunSudo(ctx, "mkdir", "-p", filepath.Dir(target)); err != nil {
|
|
return fmt.Errorf("file_sync: mkdir %s: %w", filepath.Dir(target), err)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if err := copyHostDir(ctx, runner, hostPath, target); err != nil {
|
|
return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
mode := entry.Mode
|
|
if mode == "" {
|
|
mode = "0600"
|
|
}
|
|
if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostPath, target); err != nil {
|
|
return fmt.Errorf("file_sync: install %s → %s: %w", hostPath, target, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// copyHostDir recursively copies hostDir into guestTarget using only
|
|
// `mkdir` (for subdirs) and `install` (for files). Each file's source
|
|
// permissions are preserved; ownership is forced to root:root via
|
|
// `install -o 0 -g 0`. Symlinks are followed (target content is
|
|
// copied as a regular file). Other special types (devices, FIFOs)
|
|
// are skipped silently.
|
|
func copyHostDir(ctx context.Context, runner system.CommandRunner, hostDir, guestTarget string) error {
|
|
if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil {
|
|
return err
|
|
}
|
|
entries, err := os.ReadDir(hostDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
hostChild := filepath.Join(hostDir, entry.Name())
|
|
guestChild := filepath.Join(guestTarget, entry.Name())
|
|
|
|
info, err := os.Stat(hostChild)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case info.IsDir():
|
|
if err := copyHostDir(ctx, runner, hostChild, guestChild); err != nil {
|
|
return err
|
|
}
|
|
case info.Mode().IsRegular():
|
|
mode := fmt.Sprintf("%04o", info.Mode().Perm())
|
|
if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostChild, guestChild); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// expandHostPath expands a leading "~/" against the host user's
|
|
// home. Already-absolute paths pass through unchanged.
|
|
func expandHostPath(raw, home string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if strings.HasPrefix(raw, "~/") {
|
|
return filepath.Join(home, strings.TrimPrefix(raw, "~/"))
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// guestPathRelativeToRoot returns the guest path as a relative path
|
|
// under /root (banger's work disk is mounted at /root in the guest,
|
|
// so everything syncable lives there). "~/foo" and "/root/foo" both
|
|
// return "foo"; config validation rejects anything outside that
|
|
// scope, so the string prefixes are the only forms we see here.
|
|
func guestPathRelativeToRoot(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
switch {
|
|
case raw == "~" || raw == "/root":
|
|
return ""
|
|
case strings.HasPrefix(raw, "~/"):
|
|
return strings.TrimPrefix(raw, "~/")
|
|
case strings.HasPrefix(raw, "/root/"):
|
|
return strings.TrimPrefix(raw, "/root/")
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
|
|
name, err := gitConfigValue(ctx, runner, nil, "user.name")
|
|
if err != nil {
|
|
return gitIdentity{}, err
|
|
}
|
|
if name == "" {
|
|
return gitIdentity{}, errors.New("host git user.name is empty")
|
|
}
|
|
|
|
email, err := gitConfigValue(ctx, runner, nil, "user.email")
|
|
if err != nil {
|
|
return gitIdentity{}, err
|
|
}
|
|
if email == "" {
|
|
return gitIdentity{}, errors.New("host git user.email is empty")
|
|
}
|
|
|
|
return gitIdentity{Name: name, Email: email}, nil
|
|
}
|
|
|
|
func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs []string, key string) (string, error) {
|
|
args := []string{"config"}
|
|
args = append(args, extraArgs...)
|
|
args = append(args, "--default", "", "--get", key)
|
|
out, err := runner.Run(ctx, "git", args...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(string(out)), nil
|
|
}
|
|
|
|
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error {
|
|
existing, err := runner.RunSudo(ctx, "cat", gitConfigPath)
|
|
if err != nil {
|
|
existing = nil
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "banger-gitconfig-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
if _, err := tmpFile.Write(existing); err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.name", identity.Name); err != nil {
|
|
return err
|
|
}
|
|
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil {
|
|
return err
|
|
}
|
|
_, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath)
|
|
return err
|
|
}
|
|
|
|
func (s *WorkspaceService) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
|
if s.logger == nil || err == nil {
|
|
return
|
|
}
|
|
s.logger.Warn("file_sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
|
}
|
|
|
|
func (s *WorkspaceService) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
|
|
if s.logger == nil || err == nil {
|
|
return
|
|
}
|
|
s.logger.Warn("guest git identity sync skipped", append(vmLogAttrs(vm), "source", source, "error", err.Error())...)
|
|
}
|
|
|
|
func mergeAuthorizedKey(existing, managed []byte) []byte {
|
|
managedLine := strings.TrimSpace(string(managed))
|
|
if managedLine == "" {
|
|
return append([]byte(nil), existing...)
|
|
}
|
|
|
|
lines := strings.Split(strings.ReplaceAll(string(existing), "\r\n", "\n"), "\n")
|
|
out := make([]string, 0, len(lines)+1)
|
|
found := false
|
|
for _, line := range lines {
|
|
line = strings.TrimRight(line, "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if trimmed == managedLine {
|
|
found = true
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
if !found {
|
|
out = append(out, managedLine)
|
|
}
|
|
return []byte(strings.Join(out, "\n") + "\n")
|
|
}
|