Add guest sessions and agent VM defaults
Add daemon-backed workspace and guest-session primitives so host orchestrators can prepare /root/repo, launch long-lived guest commands, and attach to pipe-mode sessions over the local stdio mux bridge. Persist richer session metadata and launch diagnostics, preflight guest cwd/command requirements, make pipe-mode attach rehydratable from guest state after daemon restart, and allow submodules when workspace prepare runs in full_copy mode. At the same time, stop vm run from auto-attaching opencode, make it print next-step commands instead, and make glibc guest images more agent-ready by installing node, opencode, claude, and pi while syncing opencode/claude/pi auth files into work disks on VM start. Validation: - GOCACHE=/tmp/banger-gocache go test ./... - make build - banger vm workspace prepare --help - banger vm session --help - banger vm session start --help - banger vm session attach --help
This commit is contained in:
parent
497e6dca3d
commit
37c4c091ec
18 changed files with 3212 additions and 405 deletions
|
|
@ -210,7 +210,13 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
|
|||
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.ensureOpencodeAuthOnWorkDisk(ctx, vm)
|
||||
if err := d.ensureOpencodeAuthOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.ensureClaudeAuthOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.ensurePiAuthOnWorkDisk(ctx, vm)
|
||||
}
|
||||
|
||||
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {
|
||||
|
|
|
|||
|
|
@ -27,32 +27,33 @@ import (
|
|||
)
|
||||
|
||||
type Daemon struct {
|
||||
layout paths.Layout
|
||||
config model.DaemonConfig
|
||||
store *store.Store
|
||||
runner system.CommandRunner
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
createOpsMu sync.Mutex
|
||||
createOps map[string]*vmCreateOperationState
|
||||
imageBuildOpsMu sync.Mutex
|
||||
imageBuildOps map[string]*imageBuildOperationState
|
||||
vmLocksMu sync.Mutex
|
||||
vmLocks map[string]*sync.Mutex
|
||||
tapPoolMu sync.Mutex
|
||||
tapPool []string
|
||||
tapPoolNext int
|
||||
closing chan struct{}
|
||||
once sync.Once
|
||||
pid int
|
||||
listener net.Listener
|
||||
webListener net.Listener
|
||||
webServer *http.Server
|
||||
webURL string
|
||||
vmDNS *vmdns.Server
|
||||
vmCaps []vmCapability
|
||||
imageBuild func(context.Context, imageBuildSpec) error
|
||||
requestHandler func(context.Context, rpc.Request) rpc.Response
|
||||
layout paths.Layout
|
||||
config model.DaemonConfig
|
||||
store *store.Store
|
||||
runner system.CommandRunner
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
createOpsMu sync.Mutex
|
||||
createOps map[string]*vmCreateOperationState
|
||||
imageBuildOpsMu sync.Mutex
|
||||
imageBuildOps map[string]*imageBuildOperationState
|
||||
vmLocksMu sync.Mutex
|
||||
vmLocks map[string]*sync.Mutex
|
||||
sessionControllers map[string]*guestSessionController
|
||||
tapPoolMu sync.Mutex
|
||||
tapPool []string
|
||||
tapPoolNext int
|
||||
closing chan struct{}
|
||||
once sync.Once
|
||||
pid int
|
||||
listener net.Listener
|
||||
webListener net.Listener
|
||||
webServer *http.Server
|
||||
webURL string
|
||||
vmDNS *vmdns.Server
|
||||
vmCaps []vmCapability
|
||||
imageBuild func(context.Context, imageBuildSpec) error
|
||||
requestHandler func(context.Context, rpc.Request) rpc.Response
|
||||
}
|
||||
|
||||
func Open(ctx context.Context) (d *Daemon, err error) {
|
||||
|
|
@ -125,7 +126,7 @@ func (d *Daemon) Close() error {
|
|||
if d.webListener != nil {
|
||||
_ = d.webListener.Close()
|
||||
}
|
||||
err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.store.Close())
|
||||
err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.closeGuestSessionControllers(), d.store.Close())
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
|
@ -396,6 +397,62 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
|
|||
}
|
||||
result, err := d.PortsVM(ctx, params.IDOrName)
|
||||
return marshalResultOrError(result, err)
|
||||
case "vm.workspace.prepare":
|
||||
params, err := rpc.DecodeParams[api.VMWorkspacePrepareParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
workspace, err := d.PrepareVMWorkspace(ctx, params)
|
||||
return marshalResultOrError(api.VMWorkspacePrepareResult{Workspace: workspace}, err)
|
||||
case "guest.session.start":
|
||||
params, err := rpc.DecodeParams[api.GuestSessionStartParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
session, err := d.StartGuestSession(ctx, params)
|
||||
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
|
||||
case "guest.session.get":
|
||||
params, err := rpc.DecodeParams[api.GuestSessionRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
session, err := d.GetGuestSession(ctx, params)
|
||||
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
|
||||
case "guest.session.list":
|
||||
params, err := rpc.DecodeParams[api.VMRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
sessions, err := d.ListGuestSessions(ctx, params)
|
||||
return marshalResultOrError(api.GuestSessionListResult{Sessions: sessions}, err)
|
||||
case "guest.session.stop":
|
||||
params, err := rpc.DecodeParams[api.GuestSessionRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
session, err := d.StopGuestSession(ctx, params)
|
||||
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
|
||||
case "guest.session.kill":
|
||||
params, err := rpc.DecodeParams[api.GuestSessionRefParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
session, err := d.KillGuestSession(ctx, params)
|
||||
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
|
||||
case "guest.session.logs":
|
||||
params, err := rpc.DecodeParams[api.GuestSessionLogsParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
result, err := d.GuestSessionLogs(ctx, params)
|
||||
return marshalResultOrError(result, err)
|
||||
case "guest.session.attach.begin":
|
||||
params, err := rpc.DecodeParams[api.GuestSessionAttachBeginParams](req)
|
||||
if err != nil {
|
||||
return rpc.NewError("bad_request", err.Error())
|
||||
}
|
||||
result, err := d.BeginGuestSessionAttach(ctx, params)
|
||||
return marshalResultOrError(result, err)
|
||||
case "image.list":
|
||||
images, err := d.store.ListImages(ctx)
|
||||
return marshalResultOrError(api.ImageListResult{Images: images}, err)
|
||||
|
|
|
|||
1198
internal/daemon/guest_sessions.go
Normal file
1198
internal/daemon/guest_sessions.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,17 +23,21 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
defaultMiseVersion = "v2025.12.0"
|
||||
defaultMiseInstallPath = "/usr/local/bin/mise"
|
||||
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
|
||||
defaultOpenCodeTool = "github:anomalyco/opencode"
|
||||
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
|
||||
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
|
||||
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
|
||||
defaultTMUXPluginDir = "/root/.tmux/plugins"
|
||||
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
|
||||
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
|
||||
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
|
||||
defaultMiseVersion = "v2025.12.0"
|
||||
defaultMiseInstallPath = "/usr/local/bin/mise"
|
||||
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
|
||||
defaultNodeTool = "node@22"
|
||||
defaultOpenCodeTool = "github:anomalyco/opencode"
|
||||
defaultClaudeCodePackage = "@anthropic-ai/claude-code"
|
||||
defaultPiPackage = "@mariozechner/pi-coding-agent"
|
||||
defaultNPMGlobalPrefix = "/root/.local/share/banger/npm-global"
|
||||
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
|
||||
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
|
||||
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
|
||||
defaultTMUXPluginDir = "/root/.tmux/plugins"
|
||||
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
|
||||
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
|
||||
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
|
||||
)
|
||||
|
||||
type imageBuildSpec struct {
|
||||
|
|
@ -302,11 +306,27 @@ func buildModulesCommand(modulesBase string) string {
|
|||
}
|
||||
|
||||
func appendMiseSetup(script *bytes.Buffer) {
|
||||
const (
|
||||
nodeShimPath = "/root/.local/share/mise/shims/node"
|
||||
npmShimPath = "/root/.local/share/mise/shims/npm"
|
||||
)
|
||||
claudePath := filepath.ToSlash(filepath.Join(defaultNPMGlobalPrefix, "bin", "claude"))
|
||||
piPath := filepath.ToSlash(filepath.Join(defaultNPMGlobalPrefix, "bin", "pi"))
|
||||
|
||||
fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultNodeTool))
|
||||
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool))
|
||||
fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi\n", shellQuote(nodeShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi\n", shellQuote(npmShimPath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath))
|
||||
fmt.Fprintf(script, "mkdir -p %s\n", shellQuote(defaultNPMGlobalPrefix))
|
||||
fmt.Fprintf(script, "NPM_CONFIG_PREFIX=%s %s install -g %s %s\n", shellQuote(defaultNPMGlobalPrefix), shellQuote(npmShimPath), shellQuote(defaultClaudeCodePackage), shellQuote(defaultPiPackage))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'claude binary not found after npm install' >&2; exit 1; fi\n", shellQuote(claudePath))
|
||||
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'pi binary not found after npm install' >&2; exit 1; fi\n", shellQuote(piPath))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(claudePath), shellQuote("/usr/local/bin/claude"))
|
||||
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(piPath), shellQuote("/usr/local/bin/pi"))
|
||||
script.WriteString("mkdir -p /etc/profile.d\n")
|
||||
script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n")
|
||||
fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath))
|
||||
|
|
|
|||
|
|
@ -18,10 +18,19 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
|
|||
"cat > /etc/systemd/system/banger-network.service <<'EOF'",
|
||||
"systemctl enable --now banger-network.service || true",
|
||||
"curl -fsSL https://mise.run | MISE_INSTALL_PATH='/usr/local/bin/mise' MISE_VERSION='v2025.12.0' sh",
|
||||
"'/usr/local/bin/mise' use -g 'node@22'",
|
||||
"'/usr/local/bin/mise' use -g 'github:anomalyco/opencode'",
|
||||
"'/usr/local/bin/mise' reshim",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/node' ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/npm' ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/mise/shims/opencode' ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi",
|
||||
"mkdir -p '/root/.local/share/banger/npm-global'",
|
||||
"NPM_CONFIG_PREFIX='/root/.local/share/banger/npm-global' '/root/.local/share/mise/shims/npm' install -g '@anthropic-ai/claude-code' '@mariozechner/pi-coding-agent'",
|
||||
"if [[ ! -e '/root/.local/share/banger/npm-global/bin/claude' ]]; then echo 'claude binary not found after npm install' >&2; exit 1; fi",
|
||||
"if [[ ! -e '/root/.local/share/banger/npm-global/bin/pi' ]]; then echo 'pi binary not found after npm install' >&2; exit 1; fi",
|
||||
"ln -snf '/root/.local/share/mise/shims/opencode' '/usr/local/bin/opencode'",
|
||||
"ln -snf '/root/.local/share/banger/npm-global/bin/claude' '/usr/local/bin/claude'",
|
||||
"ln -snf '/root/.local/share/banger/npm-global/bin/pi' '/usr/local/bin/pi'",
|
||||
"cat > /etc/profile.d/mise.sh <<'EOF'",
|
||||
"if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then",
|
||||
`eval "$(/usr/local/bin/mise activate bash)"`,
|
||||
|
|
|
|||
|
|
@ -35,8 +35,14 @@ const (
|
|||
workDiskGitConfigRelativePath = ".gitconfig"
|
||||
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
|
||||
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
|
||||
workDiskClaudeAuthDirRelativePath = ".claude"
|
||||
workDiskClaudeAuthRelativePath = workDiskClaudeAuthDirRelativePath + "/.credentials.json"
|
||||
workDiskPiAuthDirRelativePath = ".pi/agent"
|
||||
workDiskPiAuthRelativePath = workDiskPiAuthDirRelativePath + "/auth.json"
|
||||
hostGlobalGitIdentitySource = "git config --global"
|
||||
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
|
||||
hostClaudeAuthDefaultDisplayPath = "~/" + workDiskClaudeAuthRelativePath
|
||||
hostPiAuthDefaultDisplayPath = "~/" + workDiskPiAuthRelativePath
|
||||
)
|
||||
|
||||
type gitIdentity struct {
|
||||
|
|
@ -967,19 +973,60 @@ func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRe
|
|||
}
|
||||
|
||||
func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
hostAuthPath, err := resolveHostOpencodeAuthPath()
|
||||
return d.ensureAuthFileOnWorkDisk(
|
||||
ctx,
|
||||
vm,
|
||||
"syncing opencode auth",
|
||||
hostOpencodeAuthDefaultDisplayPath,
|
||||
resolveHostOpencodeAuthPath,
|
||||
workDiskOpencodeAuthRelativePath,
|
||||
d.warnOpencodeAuthSyncSkipped,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureClaudeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
return d.ensureAuthFileOnWorkDisk(
|
||||
ctx,
|
||||
vm,
|
||||
"syncing claude auth",
|
||||
hostClaudeAuthDefaultDisplayPath,
|
||||
resolveHostClaudeAuthPath,
|
||||
workDiskClaudeAuthRelativePath,
|
||||
d.warnClaudeAuthSyncSkipped,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensurePiAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
return d.ensureAuthFileOnWorkDisk(
|
||||
ctx,
|
||||
vm,
|
||||
"syncing pi auth",
|
||||
hostPiAuthDefaultDisplayPath,
|
||||
resolveHostPiAuthPath,
|
||||
workDiskPiAuthRelativePath,
|
||||
d.warnPiAuthSyncSkipped,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecord, stageDetail, defaultDisplayPath string, resolveHostPath func() (string, error), guestRelativePath string, warn func(model.VMRecord, string, error)) error {
|
||||
hostAuthPath, err := resolveHostPath()
|
||||
if err != nil {
|
||||
d.warnOpencodeAuthSyncSkipped(*vm, hostOpencodeAuthDefaultDisplayPath, err)
|
||||
warn(*vm, defaultDisplayPath, err)
|
||||
return nil
|
||||
}
|
||||
authData, err := os.ReadFile(hostAuthPath)
|
||||
if err != nil {
|
||||
d.warnOpencodeAuthSyncSkipped(*vm, hostAuthPath, err)
|
||||
warn(*vm, hostAuthPath, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
vmCreateStage(ctx, "prepare_work_disk", "syncing opencode auth")
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
|
||||
runner := d.runner
|
||||
if runner == nil {
|
||||
runner = system.NewRunner()
|
||||
}
|
||||
|
||||
vmCreateStage(ctx, "prepare_work_disk", stageDetail)
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -989,13 +1036,13 @@ func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMR
|
|||
return err
|
||||
}
|
||||
|
||||
authDir := filepath.Join(workMount, workDiskOpencodeAuthDirRelativePath)
|
||||
if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
|
||||
authDir := filepath.Join(workMount, filepath.Dir(guestRelativePath))
|
||||
if _, err := runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
|
||||
return err
|
||||
}
|
||||
authPath := filepath.Join(workMount, workDiskOpencodeAuthRelativePath)
|
||||
authPath := filepath.Join(workMount, guestRelativePath)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "banger-opencode-auth-*")
|
||||
tmpFile, err := os.CreateTemp("", "banger-auth-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -1011,16 +1058,28 @@ func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMR
|
|||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
_, err = d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath)
|
||||
_, err = runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveHostOpencodeAuthPath() (string, error) {
|
||||
return resolveHostAuthPath(workDiskOpencodeAuthRelativePath)
|
||||
}
|
||||
|
||||
func resolveHostClaudeAuthPath() (string, error) {
|
||||
return resolveHostAuthPath(workDiskClaudeAuthRelativePath)
|
||||
}
|
||||
|
||||
func resolveHostPiAuthPath() (string, error) {
|
||||
return resolveHostAuthPath(workDiskPiAuthRelativePath)
|
||||
}
|
||||
|
||||
func resolveHostAuthPath(relativePath string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil
|
||||
return filepath.Join(home, relativePath), nil
|
||||
}
|
||||
|
||||
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
|
||||
|
|
@ -1093,6 +1152,20 @@ func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string,
|
|||
d.logger.Warn("guest opencode auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnClaudeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
d.logger.Warn("guest claude auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnPiAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
d.logger.Warn("guest pi auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1102,6 +1102,124 @@ func TestEnsureOpencodeAuthOnWorkDiskWarnsAndSkipsWhenHostAuthUnreadable(t *test
|
|||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskClaudeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth dir): %v", err)
|
||||
}
|
||||
hostAuth := []byte("{\"token\":\"claude\"}\n")
|
||||
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(host auth): %v", err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("claude-auth", "image-claude-auth", "172.16.0.67")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureClaudeAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureClaudeAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskClaudeAuthRelativePath)
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(hostAuth) {
|
||||
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
|
||||
}
|
||||
info, err := os.Stat(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(guest auth): %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("guest auth mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePiAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskPiAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth dir): %v", err)
|
||||
}
|
||||
hostAuth := []byte("{\"token\":\"pi\"}\n")
|
||||
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(host auth): %v", err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("pi-auth", "image-pi-auth", "172.16.0.68")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath)
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(hostAuth) {
|
||||
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePiAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(guest auth dir): %v", err)
|
||||
}
|
||||
original := []byte("{\"token\":\"keep\"}\n")
|
||||
if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(guest auth): %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger, _, err := newDaemonLogger(&buf, "info")
|
||||
if err != nil {
|
||||
t.Fatalf("newDaemonLogger: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
logger: logger,
|
||||
}
|
||||
vm := testVM("pi-auth-missing", "image-pi-auth-missing", "172.16.0.69")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original))
|
||||
}
|
||||
|
||||
entries := parseLogEntries(t, buf.Bytes())
|
||||
if !hasLogEntry(entries, map[string]string{
|
||||
"msg": "guest pi auth sync skipped",
|
||||
"vm_name": vm.Name,
|
||||
"host_path": filepath.Join(homeDir, workDiskPiAuthRelativePath),
|
||||
}) {
|
||||
t.Fatalf("expected warn log, got %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
||||
d := &Daemon{}
|
||||
if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {
|
||||
|
|
|
|||
417
internal/daemon/workspace.go
Normal file
417
internal/daemon/workspace.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"banger/internal/api"
|
||||
"banger/internal/guest"
|
||||
"banger/internal/model"
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
const workspaceShallowFetchDepth = 10
|
||||
|
||||
type workspaceRepoSpec struct {
|
||||
SourcePath string
|
||||
RepoRoot string
|
||||
RepoName string
|
||||
HeadCommit string
|
||||
CurrentBranch string
|
||||
BranchName string
|
||||
BaseCommit string
|
||||
OriginURL string
|
||||
GitUserName string
|
||||
GitUserEmail string
|
||||
OverlayPaths []string
|
||||
Submodules []string
|
||||
}
|
||||
|
||||
func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) {
|
||||
mode, err := parseWorkspacePrepareMode(params.Mode)
|
||||
if err != nil {
|
||||
return model.WorkspacePrepareResult{}, err
|
||||
}
|
||||
guestPath := strings.TrimSpace(params.GuestPath)
|
||||
if guestPath == "" {
|
||||
guestPath = "/root/repo"
|
||||
}
|
||||
branchName := strings.TrimSpace(params.Branch)
|
||||
fromRef := strings.TrimSpace(params.From)
|
||||
if branchName != "" && fromRef == "" {
|
||||
fromRef = "HEAD"
|
||||
}
|
||||
if branchName == "" && strings.TrimSpace(params.From) != "" {
|
||||
return model.WorkspacePrepareResult{}, errors.New("workspace from requires branch")
|
||||
}
|
||||
var prepared model.WorkspacePrepareResult
|
||||
_, err = d.withVMLockByRef(ctx, params.IDOrName, func(vm model.VMRecord) (model.VMRecord, error) {
|
||||
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
||||
return model.VMRecord{}, fmt.Errorf("vm %q is not running", vm.Name)
|
||||
}
|
||||
result, err := d.prepareVMWorkspaceLocked(ctx, vm, strings.TrimSpace(params.SourcePath), guestPath, branchName, fromRef, mode, params.ReadOnly)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
}
|
||||
prepared = result
|
||||
return vm, nil
|
||||
})
|
||||
return prepared, err
|
||||
}
|
||||
|
||||
func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly bool) (model.WorkspacePrepareResult, error) {
|
||||
spec, err := inspectWorkspaceRepo(ctx, sourcePath, branchName, fromRef)
|
||||
if err != nil {
|
||||
return model.WorkspacePrepareResult{}, err
|
||||
}
|
||||
if len(spec.Submodules) > 0 && mode != model.WorkspacePrepareModeFullCopy {
|
||||
return model.WorkspacePrepareResult{}, fmt.Errorf("workspace mode %q does not support git submodules in %s (%s); use --mode full_copy", mode, spec.RepoRoot, strings.Join(spec.Submodules, ", "))
|
||||
}
|
||||
address := net.JoinHostPort(vm.Runtime.GuestIP, "22")
|
||||
if err := guest.WaitForSSH(ctx, address, d.config.SSHKeyPath, 250*time.Millisecond); err != nil {
|
||||
return model.WorkspacePrepareResult{}, fmt.Errorf("guest ssh unavailable: %w", err)
|
||||
}
|
||||
client, err := guest.Dial(ctx, address, d.config.SSHKeyPath)
|
||||
if err != nil {
|
||||
return model.WorkspacePrepareResult{}, fmt.Errorf("dial guest ssh: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := importWorkspaceRepoToGuest(ctx, client, spec, guestPath, mode); err != nil {
|
||||
return model.WorkspacePrepareResult{}, err
|
||||
}
|
||||
if readOnly {
|
||||
var chmodLog bytes.Buffer
|
||||
chmodScript := fmt.Sprintf("set -euo pipefail\nchmod -R a-w %s\n", guestShellQuote(guestPath))
|
||||
if err := client.RunScript(ctx, chmodScript, &chmodLog); err != nil {
|
||||
return model.WorkspacePrepareResult{}, formatGuestSessionStepError("set workspace readonly", err, chmodLog.String())
|
||||
}
|
||||
}
|
||||
return model.WorkspacePrepareResult{
|
||||
VMID: vm.ID,
|
||||
SourcePath: spec.SourcePath,
|
||||
RepoRoot: spec.RepoRoot,
|
||||
RepoName: spec.RepoName,
|
||||
GuestPath: guestPath,
|
||||
Mode: mode,
|
||||
ReadOnly: readOnly,
|
||||
HeadCommit: spec.HeadCommit,
|
||||
CurrentBranch: spec.CurrentBranch,
|
||||
BranchName: spec.BranchName,
|
||||
BaseCommit: spec.BaseCommit,
|
||||
PreparedAt: model.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func inspectWorkspaceRepo(ctx context.Context, rawPath, branchName, fromRef string) (workspaceRepoSpec, error) {
|
||||
sourcePath, err := resolveWorkspaceSourcePath(rawPath)
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, err
|
||||
}
|
||||
repoRoot, err := workspaceGitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath)
|
||||
}
|
||||
isBare, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err)
|
||||
}
|
||||
if isBare == "true" {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("workspace prepare requires a non-bare git repository: %s", repoRoot)
|
||||
}
|
||||
submodules, err := listWorkspaceSubmodules(ctx, repoRoot)
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, err
|
||||
}
|
||||
headCommit, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot)
|
||||
}
|
||||
currentBranch, err := workspaceGitTrimmedOutput(ctx, repoRoot, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err)
|
||||
}
|
||||
baseCommit := headCommit
|
||||
branchName = strings.TrimSpace(branchName)
|
||||
if branchName != "" {
|
||||
baseCommit, err = workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err)
|
||||
}
|
||||
}
|
||||
gitUserName, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.name")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
|
||||
}
|
||||
gitUserEmail, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.email")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
|
||||
}
|
||||
originURL, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err)
|
||||
}
|
||||
overlayPaths, err := listWorkspaceOverlayPaths(ctx, repoRoot)
|
||||
if err != nil {
|
||||
return workspaceRepoSpec{}, err
|
||||
}
|
||||
return workspaceRepoSpec{
|
||||
SourcePath: sourcePath,
|
||||
RepoRoot: repoRoot,
|
||||
RepoName: filepath.Base(repoRoot),
|
||||
HeadCommit: headCommit,
|
||||
CurrentBranch: currentBranch,
|
||||
BranchName: branchName,
|
||||
BaseCommit: baseCommit,
|
||||
OriginURL: originURL,
|
||||
GitUserName: gitUserName,
|
||||
GitUserEmail: gitUserEmail,
|
||||
OverlayPaths: overlayPaths,
|
||||
Submodules: submodules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func importWorkspaceRepoToGuest(ctx context.Context, client *guest.Client, spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
|
||||
switch mode {
|
||||
case model.WorkspacePrepareModeFullCopy:
|
||||
var copyLog bytes.Buffer
|
||||
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", guestShellQuote(guestPath), guestShellQuote(guestPath), guestShellQuote(guestPath))
|
||||
if err := client.StreamTar(ctx, spec.RepoRoot, command, ©Log); err != nil {
|
||||
return formatGuestSessionStepError("copy full workspace", err, copyLog.String())
|
||||
}
|
||||
var finalizeLog bytes.Buffer
|
||||
if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &finalizeLog); err != nil {
|
||||
return formatGuestSessionStepError("finalize full workspace", err, finalizeLog.String())
|
||||
}
|
||||
return nil
|
||||
case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay:
|
||||
repoCopyDir, cleanup, err := prepareWorkspaceRepoCopy(ctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
var copyLog bytes.Buffer
|
||||
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", guestShellQuote(guestPath), guestShellQuote(guestPath), guestShellQuote(guestPath))
|
||||
if err := client.StreamTar(ctx, repoCopyDir, command, ©Log); err != nil {
|
||||
return formatGuestSessionStepError("copy guest git metadata", err, copyLog.String())
|
||||
}
|
||||
var scriptLog bytes.Buffer
|
||||
if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &scriptLog); err != nil {
|
||||
return formatGuestSessionStepError("prepare guest checkout", err, scriptLog.String())
|
||||
}
|
||||
if mode == model.WorkspacePrepareModeMetadataOnly {
|
||||
return nil
|
||||
}
|
||||
var overlayLog bytes.Buffer
|
||||
command = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", guestShellQuote(guestPath))
|
||||
if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, command, &overlayLog); err != nil {
|
||||
return formatGuestSessionStepError("overlay workspace working tree", err, overlayLog.String())
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported workspace mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceFinalizeScript(spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) string {
|
||||
var script strings.Builder
|
||||
script.WriteString("set -euo pipefail\n")
|
||||
fmt.Fprintf(&script, "DIR=%s\n", guestShellQuote(guestPath))
|
||||
script.WriteString("git config --global --add safe.directory \"$DIR\"\n")
|
||||
if mode != model.WorkspacePrepareModeFullCopy {
|
||||
script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n")
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(spec.BranchName) != "":
|
||||
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", guestShellQuote(spec.BranchName), guestShellQuote(spec.BaseCommit))
|
||||
case strings.TrimSpace(spec.CurrentBranch) != "":
|
||||
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", guestShellQuote(spec.CurrentBranch), guestShellQuote(spec.HeadCommit))
|
||||
default:
|
||||
fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", guestShellQuote(spec.HeadCommit))
|
||||
}
|
||||
if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" {
|
||||
fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", guestShellQuote(spec.GitUserName))
|
||||
fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", guestShellQuote(spec.GitUserEmail))
|
||||
}
|
||||
return script.String()
|
||||
}
|
||||
|
||||
func prepareWorkspaceRepoCopy(ctx context.Context, spec workspaceRepoSpec) (string, func(), error) {
|
||||
tempRoot, err := os.MkdirTemp("", "banger-workspace-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(tempRoot) }
|
||||
repoCopyDir := filepath.Join(tempRoot, spec.RepoName)
|
||||
cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth)}
|
||||
if strings.TrimSpace(spec.CurrentBranch) != "" {
|
||||
cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch)
|
||||
}
|
||||
cloneArgs = append(cloneArgs, workspaceGitFileURL(spec.RepoRoot), repoCopyDir)
|
||||
if err := workspaceRunHostCommand(ctx, "git", cloneArgs...); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err)
|
||||
}
|
||||
checkoutCommit := spec.HeadCommit
|
||||
if strings.TrimSpace(spec.BranchName) != "" {
|
||||
checkoutCommit = spec.BaseCommit
|
||||
}
|
||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
|
||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth), workspaceGitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("fetch shallow workspace repo commit %s: %w", checkoutCommit, err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(spec.OriginURL) != "" {
|
||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("set workspace origin remote: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err)
|
||||
}
|
||||
}
|
||||
return repoCopyDir, cleanup, nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceSourcePath(rawPath string) (string, error) {
|
||||
if strings.TrimSpace(rawPath) == "" {
|
||||
return "", errors.New("workspace source path is required")
|
||||
}
|
||||
absPath, err := filepath.Abs(rawPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("%s is not a directory", absPath)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func listWorkspaceSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
|
||||
output, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--stage", "-z")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err)
|
||||
}
|
||||
var submodules []string
|
||||
for _, record := range workspaceParseNullSeparatedOutput(output) {
|
||||
if !strings.HasPrefix(record, "160000 ") {
|
||||
continue
|
||||
}
|
||||
_, path, ok := strings.Cut(record, " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
submodules = append(submodules, strings.TrimSpace(path))
|
||||
}
|
||||
sort.Strings(submodules)
|
||||
return submodules, nil
|
||||
}
|
||||
|
||||
func listWorkspaceOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) {
|
||||
trackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "-z")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err)
|
||||
}
|
||||
untrackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
|
||||
}
|
||||
paths := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, relPath := range workspaceParseNullSeparatedOutput(trackedOutput) {
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
seen[relPath] = struct{}{}
|
||||
paths = append(paths, relPath)
|
||||
}
|
||||
for _, relPath := range workspaceParseNullSeparatedOutput(untrackedOutput) {
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[relPath]; ok {
|
||||
continue
|
||||
}
|
||||
seen[relPath] = struct{}{}
|
||||
paths = append(paths, relPath)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func parseWorkspacePrepareMode(raw string) (model.WorkspacePrepareMode, error) {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "", string(model.WorkspacePrepareModeShallowOverlay):
|
||||
return model.WorkspacePrepareModeShallowOverlay, nil
|
||||
case string(model.WorkspacePrepareModeFullCopy):
|
||||
return model.WorkspacePrepareModeFullCopy, nil
|
||||
case string(model.WorkspacePrepareModeMetadataOnly):
|
||||
return model.WorkspacePrepareModeMetadataOnly, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported workspace mode %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceGitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
fullArgs := make([]string, 0, len(args)+2)
|
||||
if strings.TrimSpace(dir) != "" {
|
||||
fullArgs = append(fullArgs, "-C", dir)
|
||||
}
|
||||
fullArgs = append(fullArgs, args...)
|
||||
return guestSessionHostCommandOutputFunc(ctx, "git", fullArgs...)
|
||||
}
|
||||
|
||||
func workspaceGitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
output, err := workspaceGitOutput(ctx, dir, args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func workspaceGitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
|
||||
return workspaceGitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
|
||||
}
|
||||
|
||||
func workspaceParseNullSeparatedOutput(output []byte) []string {
|
||||
chunks := bytes.Split(output, []byte{0})
|
||||
values := make([]string, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
value := strings.TrimSpace(string(chunk))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func workspaceRunHostCommand(ctx context.Context, name string, args ...string) error {
|
||||
_, err := guestSessionHostCommandOutputFunc(ctx, name, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func workspaceGitFileURL(path string) string {
|
||||
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue