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
|
|
@ -150,6 +150,73 @@ type VMPortsResult struct {
|
|||
Ports []VMPort `json:"ports"`
|
||||
}
|
||||
|
||||
type GuestSessionStartParams struct {
|
||||
VMIDOrName string `json:"vm_id_or_name"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
CWD string `json:"cwd,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
StdinMode string `json:"stdin_mode,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
RequiredCommands []string `json:"required_commands,omitempty"`
|
||||
}
|
||||
|
||||
type GuestSessionRefParams struct {
|
||||
VMIDOrName string `json:"vm_id_or_name"`
|
||||
SessionIDOrName string `json:"session_id_or_name"`
|
||||
}
|
||||
|
||||
type GuestSessionLogsParams struct {
|
||||
VMIDOrName string `json:"vm_id_or_name"`
|
||||
SessionIDOrName string `json:"session_id_or_name"`
|
||||
Stream string `json:"stream,omitempty"`
|
||||
TailLines int `json:"tail_lines,omitempty"`
|
||||
}
|
||||
|
||||
type GuestSessionAttachBeginParams struct {
|
||||
VMIDOrName string `json:"vm_id_or_name"`
|
||||
SessionIDOrName string `json:"session_id_or_name"`
|
||||
}
|
||||
|
||||
type GuestSessionListResult struct {
|
||||
Sessions []model.GuestSession `json:"sessions"`
|
||||
}
|
||||
|
||||
type GuestSessionShowResult struct {
|
||||
Session model.GuestSession `json:"session"`
|
||||
}
|
||||
|
||||
type GuestSessionLogsResult struct {
|
||||
Session model.GuestSession `json:"session"`
|
||||
Stream string `json:"stream"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type GuestSessionAttachBeginResult struct {
|
||||
Session model.GuestSession `json:"session"`
|
||||
AttachID string `json:"attach_id"`
|
||||
TransportKind string `json:"transport_kind"`
|
||||
TransportTarget string `json:"transport_target"`
|
||||
SocketPath string `json:"socket_path,omitempty"`
|
||||
StreamFormat string `json:"stream_format"`
|
||||
}
|
||||
|
||||
type VMWorkspacePrepareParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
SourcePath string `json:"source_path"`
|
||||
GuestPath string `json:"guest_path,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ReadOnly bool `json:"readonly,omitempty"`
|
||||
}
|
||||
|
||||
type VMWorkspacePrepareResult struct {
|
||||
Workspace model.WorkspacePrepareResult `json:"workspace"`
|
||||
}
|
||||
|
||||
type ImageBuildParams struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
FromImage string `json:"from_image,omitempty"`
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import (
|
|||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
"banger/internal/rpc"
|
||||
"banger/internal/sessionstream"
|
||||
"banger/internal/system"
|
||||
"banger/internal/toolingplan"
|
||||
"banger/internal/vmdns"
|
||||
|
|
@ -50,15 +51,7 @@ var (
|
|||
sshCmd.Stdin = stdin
|
||||
return sshCmd.Run()
|
||||
}
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
opencodeCmd := exec.CommandContext(ctx, "opencode", args...)
|
||||
opencodeCmd.Stdout = stdout
|
||||
opencodeCmd.Stderr = stderr
|
||||
opencodeCmd.Stdin = stdin
|
||||
return opencodeCmd.Run()
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = hostOpencodeAttachSupported
|
||||
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
|
|
@ -93,6 +86,30 @@ var (
|
|||
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
|
||||
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return rpc.Call[api.VMWorkspacePrepareResult](ctx, socketPath, "vm.workspace.prepare", params)
|
||||
}
|
||||
guestSessionStartFunc = func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
|
||||
}
|
||||
guestSessionGetFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.get", params)
|
||||
}
|
||||
guestSessionListFunc = func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error) {
|
||||
return rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: idOrName})
|
||||
}
|
||||
guestSessionStopFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.stop", params)
|
||||
}
|
||||
guestSessionKillFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
|
||||
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.kill", params)
|
||||
}
|
||||
guestSessionLogsFunc = func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error) {
|
||||
return rpc.Call[api.GuestSessionLogsResult](ctx, socketPath, "guest.session.logs", params)
|
||||
}
|
||||
guestSessionAttachBeginFunc = func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
|
||||
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
|
||||
}
|
||||
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
|
||||
}
|
||||
|
|
@ -119,6 +136,7 @@ type vmRunRepoSpec struct {
|
|||
HeadCommit string
|
||||
CurrentBranch string
|
||||
BranchName string
|
||||
FromRef string
|
||||
BaseCommit string
|
||||
OriginURL string
|
||||
GitUserName string
|
||||
|
|
@ -128,22 +146,8 @@ type vmRunRepoSpec struct {
|
|||
|
||||
const vmRunShallowFetchDepth = 10
|
||||
|
||||
const vmRunToolingHarnessModel = "opencode/mimo-v2-pro-free"
|
||||
const vmRunToolingHarnessTimeoutSeconds = 45
|
||||
const vmRunToolingInstallTimeoutSeconds = 120
|
||||
|
||||
const vmRunToolingHarnessPrompt = `You are preparing a development VM for this repository.
|
||||
|
||||
Inspect the repository for developer tools and binaries that are clearly needed to work on it. Look at files like .mise.toml, .tool-versions, README/setup docs, CI config, task runners, scripts, and build manifests.
|
||||
|
||||
Rules:
|
||||
- Use mise only for installs.
|
||||
- Do not edit repository files.
|
||||
- Prefer repo-declared versions first.
|
||||
- If a tool is clearly required but not pinned, you may install a conservative guest-global tool with mise.
|
||||
- Skip ambiguous installs instead of guessing.
|
||||
- End with a short summary of what you installed and what you skipped.`
|
||||
|
||||
func NewBangerCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "banger",
|
||||
|
|
@ -464,6 +468,8 @@ func newVMCommand() *cobra.Command {
|
|||
newVMSetCommand(),
|
||||
newVMSSHCommand(),
|
||||
newVMACPCommand(),
|
||||
newVMWorkspaceCommand(),
|
||||
newVMSessionCommand(),
|
||||
newVMLogsCommand(),
|
||||
newVMStatsCommand(),
|
||||
newVMPortsCommand(),
|
||||
|
|
@ -485,8 +491,13 @@ func newVMRunCommand() *cobra.Command {
|
|||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "run [path]",
|
||||
Short: "Create a repo-backed VM session and attach opencode",
|
||||
Short: "Create repo-backed VM and print next steps",
|
||||
Long: "Create a VM for a local git repository, prepare /root/repo inside the guest, start best-effort mise tooling bootstrap, and print manual access commands.",
|
||||
Args: maxArgsUsage(1, "usage: banger vm run [path]"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm run
|
||||
banger vm run ../repo --name agent-box --branch feature/demo
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
|
||||
return errors.New("--branch requires a branch name")
|
||||
|
|
@ -835,7 +846,7 @@ func newVMACPCommand() *cobra.Command {
|
|||
var cwd string
|
||||
cmd := &cobra.Command{
|
||||
Use: "acp <id-or-name>",
|
||||
Short: "Bridge ACP to a running VM over SSH",
|
||||
Short: "Bridge local stdio to guest opencode acp over SSH",
|
||||
Args: exactArgsUsage(1, "usage: banger vm acp [--cwd PATH] <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
||||
|
|
@ -852,6 +863,393 @@ func newVMACPCommand() *cobra.Command {
|
|||
return cmd
|
||||
}
|
||||
|
||||
func newVMWorkspaceCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "workspace",
|
||||
Short: "Manage repository workspaces inside a running VM",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(newVMWorkspacePrepareCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMWorkspacePrepareCommand() *cobra.Command {
|
||||
var guestPath string
|
||||
var branchName string
|
||||
var fromRef string
|
||||
var mode string
|
||||
var readOnly bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "prepare <id-or-name> [path]",
|
||||
Short: "Copy a local repo into a running VM",
|
||||
Long: "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.",
|
||||
Args: minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm workspace prepare devbox
|
||||
banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly
|
||||
banger vm workspace prepare devbox ../repo --mode full_copy
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePath := ""
|
||||
if len(args) > 1 {
|
||||
sourcePath = args[1]
|
||||
}
|
||||
resolvedPath, err := resolveVMRunSourcePath(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prepareFrom := ""
|
||||
if strings.TrimSpace(branchName) != "" {
|
||||
prepareFrom = fromRef
|
||||
}
|
||||
result, err := vmWorkspacePrepareFunc(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
|
||||
IDOrName: args[0],
|
||||
SourcePath: resolvedPath,
|
||||
GuestPath: guestPath,
|
||||
Branch: branchName,
|
||||
From: prepareFrom,
|
||||
Mode: mode,
|
||||
ReadOnly: readOnly,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.Workspace)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
|
||||
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
|
||||
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch")
|
||||
cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
|
||||
cmd.Flags().BoolVar(&readOnly, "readonly", false, "make the prepared workspace read-only")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Manage long-lived guest commands inside a VM",
|
||||
Long: "Start, inspect, stop, and attach to daemon-managed guest commands. Pipe-mode sessions expose live stdio for interactive protocols. Attach is exclusive and currently uses a same-host local bridge.",
|
||||
RunE: helpNoArgs,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newVMSessionStartCommand(),
|
||||
newVMSessionListCommand(),
|
||||
newVMSessionShowCommand(),
|
||||
newVMSessionLogsCommand(),
|
||||
newVMSessionStopCommand(),
|
||||
newVMSessionKillCommand(),
|
||||
newVMSessionAttachCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionStartCommand() *cobra.Command {
|
||||
var name string
|
||||
var cwd string
|
||||
var stdinMode string
|
||||
var envPairs []string
|
||||
var tagPairs []string
|
||||
var requiredCommands []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "start <id-or-name> <command> [args...]",
|
||||
Short: "Start a managed guest command",
|
||||
Long: "Start a daemon-managed guest command. The daemon verifies that the guest working directory exists and that the requested command is present in guest PATH before launch. Use --stdin-mode pipe when you need live attach.",
|
||||
Args: minArgsUsage(2, "usage: banger vm session start <id-or-name> [flags] -- <command> [args...]"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm session start devbox --name planner --cwd /root/repo --stdin-mode pipe --require-command git -- pi --mode rpc --no-session
|
||||
banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash'
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := parseKeyValuePairs(envPairs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := parseKeyValuePairs(tagPairs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionStartFunc(cmd.Context(), layout.SocketPath, api.GuestSessionStartParams{
|
||||
VMIDOrName: args[0],
|
||||
Name: name,
|
||||
Command: args[1],
|
||||
Args: append([]string(nil), args[2:]...),
|
||||
CWD: cwd,
|
||||
Env: env,
|
||||
StdinMode: stdinMode,
|
||||
Tags: tags,
|
||||
RequiredCommands: append([]string(nil), requiredCommands...),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := printGuestSessionSummary(cmd.OutOrStdout(), result.Session); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Session.Status == model.GuestSessionStatusFailed && strings.TrimSpace(result.Session.LaunchMessage) != "" {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: session failed at %s: %s\n", result.Session.LaunchStage, result.Session.LaunchMessage)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "session name")
|
||||
cmd.Flags().StringVar(&cwd, "cwd", "", "guest working directory; must already exist")
|
||||
cmd.Flags().StringVar(&stdinMode, "stdin-mode", string(model.GuestSessionStdinClosed), "stdin mode: closed or pipe (pipe enables attach)")
|
||||
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment entry in KEY=VALUE form")
|
||||
cmd.Flags().StringArrayVar(&tagPairs, "tag", nil, "session tag in KEY=VALUE form")
|
||||
cmd.Flags().StringArrayVar(&requiredCommands, "require-command", nil, "extra guest command that must exist in PATH before launch; repeatable")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list <id-or-name>",
|
||||
Short: "List managed guest commands for a VM",
|
||||
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionListFunc(cmd.Context(), layout.SocketPath, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionTable(cmd.OutOrStdout(), result.Sessions)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionShowCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show <id-or-name> <session>",
|
||||
Short: "Show managed guest command details",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionGetFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionLogsCommand() *cobra.Command {
|
||||
var stream string
|
||||
var tailLines int
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs <id-or-name> <session>",
|
||||
Short: "Show stdout or stderr for a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionLogsFunc(cmd.Context(), layout.SocketPath, api.GuestSessionLogsParams{VMIDOrName: args[0], SessionIDOrName: args[1], Stream: stream, TailLines: tailLines})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(cmd.OutOrStdout(), result.Content)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&stream, "stream", "stdout", "log stream to read")
|
||||
cmd.Flags().IntVarP(&tailLines, "lines", "n", 200, "number of lines to tail")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVMSessionStopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <id-or-name> <session>",
|
||||
Short: "Send SIGTERM to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionStopFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionKillCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "kill <id-or-name> <session>",
|
||||
Short: "Send SIGKILL to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionKillFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVMSessionAttachCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "attach <id-or-name> <session>",
|
||||
Short: "Attach local stdio to an attachable guest session",
|
||||
Long: "Attach local stdio to a pipe-mode session through a daemon-created local Unix socket bridge. Only one active attach is allowed at a time, and the client must run on the same host as the daemon.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := guestSessionAttachBeginFunc(cmd.Context(), layout.SocketPath, api.GuestSessionAttachBeginParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socketPath := strings.TrimSpace(result.SocketPath)
|
||||
if socketPath == "" && result.TransportKind == "unix_socket" {
|
||||
socketPath = strings.TrimSpace(result.TransportTarget)
|
||||
}
|
||||
return runGuestSessionAttach(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), socketPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseKeyValuePairs(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make(map[string]string, len(values))
|
||||
for _, value := range values {
|
||||
key, raw, ok := strings.Cut(value, "=")
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
return nil, fmt.Errorf("invalid key=value entry %q", value)
|
||||
}
|
||||
result[strings.TrimSpace(key)] = raw
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func printGuestSessionSummary(out anyWriter, session model.GuestSession) error {
|
||||
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\n", session.ID, session.Name, session.Status, session.Command, session.CWD)
|
||||
return err
|
||||
}
|
||||
|
||||
func printGuestSessionTable(out io.Writer, sessions []model.GuestSession) error {
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tATTACH\tCOMMAND\tCWD"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
attach := "no"
|
||||
if session.Attachable {
|
||||
attach = "yes"
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", shortID(session.ID), session.Name, session.Status, attach, session.Command, session.CWD); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func runGuestSessionAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, socketPath string) error {
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
writeErrCh <- streamGuestSessionAttachInput(conn, stdin)
|
||||
}()
|
||||
for {
|
||||
channel, payload, err := sessionstream.ReadFrame(conn)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
switch channel {
|
||||
case sessionstream.ChannelStdout:
|
||||
if _, err := stdout.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
case sessionstream.ChannelStderr:
|
||||
if _, err := stderr.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
case sessionstream.ChannelControl:
|
||||
message, err := sessionstream.ReadControl(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch message.Type {
|
||||
case "exit":
|
||||
if message.ExitCode != nil && *message.ExitCode != 0 {
|
||||
return fmt.Errorf("guest session exited with code %d", *message.ExitCode)
|
||||
}
|
||||
return nil
|
||||
case "error":
|
||||
if strings.TrimSpace(message.Error) == "" {
|
||||
return errors.New("guest session attach failed")
|
||||
}
|
||||
return errors.New(message.Error)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-writeErrCh:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamGuestSessionAttachInput(conn net.Conn, stdin io.Reader) error {
|
||||
if stdin == nil {
|
||||
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
|
||||
}
|
||||
buffer := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := stdin.Read(buffer)
|
||||
if n > 0 {
|
||||
if writeErr := sessionstream.WriteFrame(conn, sessionstream.ChannelStdin, buffer[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newVMLogsCommand() *cobra.Command {
|
||||
var follow bool
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -1532,7 +1930,6 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
|||
func validateVMRunPrereqs(cfg model.DaemonConfig) error {
|
||||
checks := system.NewPreflight()
|
||||
checks.RequireCommand("git", "install git")
|
||||
checks.RequireCommand("opencode", "install opencode")
|
||||
if strings.TrimSpace(cfg.SSHKeyPath) != "" {
|
||||
checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`)
|
||||
}
|
||||
|
|
@ -1570,12 +1967,14 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
|
|||
}
|
||||
|
||||
baseCommit := headCommit
|
||||
resolvedFromRef := ""
|
||||
branchName = strings.TrimSpace(branchName)
|
||||
if branchName != "" {
|
||||
fromRef = strings.TrimSpace(fromRef)
|
||||
if fromRef == "" {
|
||||
return vmRunRepoSpec{}, errors.New("--from cannot be empty")
|
||||
}
|
||||
resolvedFromRef = fromRef
|
||||
baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
||||
if err != nil {
|
||||
return vmRunRepoSpec{}, fmt.Errorf("resolve --from %q: %w", fromRef, err)
|
||||
|
|
@ -1607,6 +2006,7 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
|
|||
HeadCommit: headCommit,
|
||||
CurrentBranch: currentBranch,
|
||||
BranchName: branchName,
|
||||
FromRef: resolvedFromRef,
|
||||
BaseCommit: baseCommit,
|
||||
OriginURL: originURL,
|
||||
GitUserName: gitUserName,
|
||||
|
|
@ -1733,6 +2133,17 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
|
|||
if vmRef == "" {
|
||||
vmRef = shortID(vm.ID)
|
||||
}
|
||||
progress.render("preparing guest workspace")
|
||||
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
|
||||
IDOrName: vmRef,
|
||||
SourcePath: spec.SourcePath,
|
||||
GuestPath: vmRunGuestDir(),
|
||||
Branch: spec.BranchName,
|
||||
From: spec.FromRef,
|
||||
Mode: string(model.WorkspacePrepareModeShallowOverlay),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err)
|
||||
}
|
||||
sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22")
|
||||
progress.render("waiting for guest ssh")
|
||||
if err := guestWaitForSSHFunc(ctx, sshAddress, cfg.SSHKeyPath, 250*time.Millisecond); err != nil {
|
||||
|
|
@ -1743,16 +2154,13 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
|
|||
return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := importVMRunRepoToGuest(ctx, client, spec, progress); err != nil {
|
||||
return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err)
|
||||
}
|
||||
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
|
||||
printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err))
|
||||
printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err))
|
||||
}
|
||||
if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(), progress); err != nil {
|
||||
return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err)
|
||||
if progress != nil {
|
||||
progress.render("printing next steps")
|
||||
}
|
||||
return nil
|
||||
return printVMRunNextSteps(stdout, vm)
|
||||
}
|
||||
|
||||
func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
|
||||
|
|
@ -1876,59 +2284,29 @@ func vmRunToolingHarnessPath(repoName string) string {
|
|||
return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".sh"))
|
||||
}
|
||||
|
||||
func vmRunToolingHarnessPromptPath(repoName string) string {
|
||||
return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".prompt.txt"))
|
||||
}
|
||||
|
||||
func vmRunToolingHarnessLogPath(repoName string) string {
|
||||
return filepath.ToSlash(filepath.Join("/root/.cache/banger", "vm-run-tooling-"+repoName+".log"))
|
||||
}
|
||||
|
||||
func startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
|
||||
if progress != nil {
|
||||
progress.render("starting tooling harness")
|
||||
progress.render("starting guest tooling bootstrap")
|
||||
}
|
||||
plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot)
|
||||
var uploadLog bytes.Buffer
|
||||
if err := client.UploadFile(ctx, vmRunToolingHarnessPromptPath(spec.RepoName), 0o644, []byte(vmRunToolingHarnessPromptData(plan)), &uploadLog); err != nil {
|
||||
return formatVMRunStepError("upload tooling harness prompt", err, uploadLog.String())
|
||||
}
|
||||
uploadLog.Reset()
|
||||
if err := client.UploadFile(ctx, vmRunToolingHarnessPath(spec.RepoName), 0o755, []byte(vmRunToolingHarnessScript(spec, plan)), &uploadLog); err != nil {
|
||||
return formatVMRunStepError("upload tooling harness", err, uploadLog.String())
|
||||
return formatVMRunStepError("upload guest tooling bootstrap", err, uploadLog.String())
|
||||
}
|
||||
var launchLog bytes.Buffer
|
||||
if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil {
|
||||
return formatVMRunStepError("launch tooling harness", err, launchLog.String())
|
||||
return formatVMRunStepError("launch guest tooling bootstrap", err, launchLog.String())
|
||||
}
|
||||
if progress != nil {
|
||||
progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName))
|
||||
progress.render("guest tooling log: " + vmRunToolingHarnessLogPath(spec.RepoName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vmRunToolingHarnessPromptData(plan toolingplan.Plan) string {
|
||||
var prompt strings.Builder
|
||||
prompt.WriteString(vmRunToolingHarnessPrompt)
|
||||
lines := make([]string, 0, len(plan.RepoManagedTools)+len(plan.Steps)+len(plan.Skips))
|
||||
for _, tool := range plan.RepoManagedTools {
|
||||
lines = append(lines, fmt.Sprintf("- Repo already declares %s through mise", tool))
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
lines = append(lines, fmt.Sprintf("- Planned deterministic install: %s@%s from %s", step.Tool, step.Version, step.Source))
|
||||
}
|
||||
for _, skip := range plan.Skips {
|
||||
lines = append(lines, fmt.Sprintf("- Deterministic skip: %s (%s)", skip.Target, skip.Reason))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, "- No deterministic prepass actions were planned")
|
||||
}
|
||||
prompt.WriteString("\n\nDeterministic prepass summary:\n")
|
||||
prompt.WriteString(strings.Join(lines, "\n"))
|
||||
prompt.WriteString("\n\nDo not repeat the deterministic prepass work unless it clearly failed. Focus on the remaining gaps.\n")
|
||||
return prompt.String()
|
||||
}
|
||||
|
||||
func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string {
|
||||
var script strings.Builder
|
||||
script.WriteString("set -uo pipefail\n")
|
||||
|
|
@ -1980,12 +2358,11 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
|
|||
script.WriteString("}\n")
|
||||
script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n")
|
||||
script.WriteString("MISE_BIN=\"$(command -v mise || true)\"\n")
|
||||
script.WriteString("OPENCODE_BIN=\"$(command -v opencode || true)\"\n")
|
||||
script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping tooling harness\"; exit 0; fi\n")
|
||||
script.WriteString("if [ -z \"$OPENCODE_BIN\" ]; then log \"opencode not found; skipping tooling harness\"; exit 0; fi\n")
|
||||
fmt.Fprintf(&script, "PROMPT_FILE=%s\n", shellQuote(vmRunToolingHarnessPromptPath(spec.RepoName)))
|
||||
script.WriteString("if [ ! -f \"$PROMPT_FILE\" ]; then log \"tooling prompt file missing: $PROMPT_FILE\"; exit 0; fi\n")
|
||||
script.WriteString("log \"starting tooling harness in $DIR\"\n")
|
||||
script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping guest tooling bootstrap\"; exit 0; fi\n")
|
||||
script.WriteString("log \"starting guest tooling bootstrap in $DIR\"\n")
|
||||
if len(plan.RepoManagedTools) > 0 {
|
||||
fmt.Fprintf(&script, "log %s\n", shellQuote("repo-managed mise tools: "+strings.Join(plan.RepoManagedTools, ", ")))
|
||||
}
|
||||
script.WriteString("if [ -f .mise.toml ] || [ -f .tool-versions ]; then\n")
|
||||
script.WriteString(" log \"running mise install from repo declarations\"\n")
|
||||
script.WriteString(" run_best_effort \"$MISE_BIN\" install\n")
|
||||
|
|
@ -2003,11 +2380,7 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
|
|||
if len(plan.Steps) > 0 {
|
||||
script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n")
|
||||
}
|
||||
fmt.Fprintf(&script, "MODEL=%s\n", shellQuote(vmRunToolingHarnessModel))
|
||||
fmt.Fprintf(&script, "TIMEOUT_SECS=%d\n", vmRunToolingHarnessTimeoutSeconds)
|
||||
script.WriteString("log \"running bounded opencode repo tooling inspection with $MODEL for up to ${TIMEOUT_SECS}s\"\n")
|
||||
script.WriteString("run_bounded_best_effort \"$TIMEOUT_SECS\" bash -lc 'exec \"$1\" run --format json -m \"$2\" \"$(cat \"$3\")\"' _ \"$OPENCODE_BIN\" \"$MODEL\" \"$PROMPT_FILE\"\n")
|
||||
script.WriteString("log \"tooling harness finished\"\n")
|
||||
script.WriteString("log \"guest tooling bootstrap finished\"\n")
|
||||
return script.String()
|
||||
}
|
||||
|
||||
|
|
@ -2022,46 +2395,31 @@ func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
|
|||
return script.String()
|
||||
}
|
||||
|
||||
func runVMRunAttach(ctx context.Context, socketPath, vmRef string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string, progress *vmRunProgressRenderer) error {
|
||||
guestIP = strings.TrimSpace(guestIP)
|
||||
if guestIP == "" {
|
||||
return errors.New("vm has no guest IP")
|
||||
func printVMRunNextSteps(out io.Writer, vm model.VMRecord) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
|
||||
if err != nil {
|
||||
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
|
||||
vmRef := strings.TrimSpace(vm.Name)
|
||||
if vmRef == "" {
|
||||
vmRef = shortID(vm.ID)
|
||||
}
|
||||
if supportsAttach {
|
||||
if progress != nil {
|
||||
progress.render("attaching opencode")
|
||||
}
|
||||
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
|
||||
"attach",
|
||||
"--dir", guestDir,
|
||||
"http://" + net.JoinHostPort(guestIP, "4096"),
|
||||
})
|
||||
hostRef := strings.TrimSpace(vm.Runtime.DNSName)
|
||||
if hostRef == "" {
|
||||
hostRef = strings.TrimSpace(vm.Runtime.GuestIP)
|
||||
}
|
||||
if progress != nil {
|
||||
progress.render("host opencode has no attach support; starting guest opencode over ssh")
|
||||
}
|
||||
sshArgs, err := sshCommandArgs(cfg, guestIP, []string{"bash", "-lc", fmt.Sprintf("cd %s && exec opencode .", shellQuote(guestDir))})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
|
||||
}
|
||||
|
||||
func hostOpencodeAttachSupported(ctx context.Context) (bool, error) {
|
||||
output, err := hostCommandOutputFunc(ctx, "opencode", "attach", "--help")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return opencodeAttachHelpOutputSupported(output), nil
|
||||
}
|
||||
|
||||
func opencodeAttachHelpOutputSupported(output []byte) bool {
|
||||
text := strings.ToLower(string(output))
|
||||
return strings.Contains(text, "opencode attach")
|
||||
guestDir := vmRunGuestDir()
|
||||
_, err := fmt.Fprintf(out, `VM ready.
|
||||
Name: %s
|
||||
Host: %s
|
||||
Repo: %s
|
||||
Next:
|
||||
banger vm ssh %s
|
||||
opencode attach http://%s:4096 --dir %s
|
||||
banger vm acp %s
|
||||
banger vm ssh %s -- "cd %s && claude"
|
||||
banger vm ssh %s -- "cd %s && pi"
|
||||
`, vmRef, hostRef, guestDir, vmRef, hostRef, guestDir, vmRef, vmRef, guestDir, vmRef, guestDir)
|
||||
return err
|
||||
}
|
||||
|
||||
func formatVMRunStepError(action string, err error, log string) error {
|
||||
|
|
@ -2098,6 +2456,7 @@ func (r *vmRunProgressRenderer) render(detail string) {
|
|||
}
|
||||
|
||||
func formatVMRunProgress(detail string) string {
|
||||
|
||||
detail = strings.TrimSpace(detail)
|
||||
if detail == "" {
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -1232,7 +1232,7 @@ func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||
func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
|
||||
|
||||
|
|
@ -1243,8 +1243,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
|
|
@ -1253,8 +1252,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1310,22 +1308,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
}
|
||||
return repoCopyDir, func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
var workspaceParams api.VMWorkspacePrepareParams
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
workspaceParams = params
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
|
||||
return toolingplan.Plan{
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
RepoManagedTools: []string{"go"},
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
}
|
||||
}
|
||||
var attachArgs []string
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
attachArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
spec := vmRunRepoSpec{
|
||||
SourcePath: repoRoot,
|
||||
RepoRoot: repoRoot,
|
||||
RepoName: "repo",
|
||||
HeadCommit: "deadbeef",
|
||||
|
|
@ -1336,13 +1333,15 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
GitUserEmail: "repo@example.com",
|
||||
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&bytes.Buffer{},
|
||||
&stdout,
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
spec,
|
||||
)
|
||||
|
|
@ -1365,29 +1364,20 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
if dialKeyPath != waitKeyPath {
|
||||
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
|
||||
}
|
||||
if fakeClient.tarSourceDir != repoCopyDir {
|
||||
t.Fatalf("tarSourceDir = %q, want %q", fakeClient.tarSourceDir, repoCopyDir)
|
||||
if workspaceParams.IDOrName != "devbox" {
|
||||
t.Fatalf("workspaceParams.IDOrName = %q, want devbox", workspaceParams.IDOrName)
|
||||
}
|
||||
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
||||
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
|
||||
if workspaceParams.SourcePath != repoRoot {
|
||||
t.Fatalf("workspaceParams.SourcePath = %q, want %q", workspaceParams.SourcePath, repoRoot)
|
||||
}
|
||||
if len(fakeClient.uploads) != 2 {
|
||||
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
|
||||
if workspaceParams.GuestPath != "/root/repo" {
|
||||
t.Fatalf("workspaceParams.GuestPath = %q, want /root/repo", workspaceParams.GuestPath)
|
||||
}
|
||||
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
|
||||
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
|
||||
if workspaceParams.Mode != string(model.WorkspacePrepareModeShallowOverlay) {
|
||||
t.Fatalf("workspaceParams.Mode = %q", workspaceParams.Mode)
|
||||
}
|
||||
if fakeClient.uploads[0].mode != 0o644 {
|
||||
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploads[0].data), `Do not edit repository files.`) {
|
||||
t.Fatalf("prompt upload data = %q, want prompt body", string(fakeClient.uploads[0].data))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploads[0].data), `Planned deterministic install: go@1.25.0 from go.mod`) {
|
||||
t.Fatalf("prompt upload data = %q, want deterministic install summary", string(fakeClient.uploads[0].data))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploads[0].data), `Deterministic skip: python (no .python-version)`) {
|
||||
t.Fatalf("prompt upload data = %q, want deterministic skip summary", string(fakeClient.uploads[0].data))
|
||||
if len(fakeClient.uploads) != 1 {
|
||||
t.Fatalf("uploads = %d, want 1", len(fakeClient.uploads))
|
||||
}
|
||||
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
|
||||
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
|
||||
|
|
@ -1395,23 +1385,17 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
if fakeClient.uploadMode != 0o755 {
|
||||
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `repo-managed mise tools: go`) {
|
||||
t.Fatalf("uploadData = %q, want repo-managed tool log", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
|
||||
t.Fatalf("uploadData = %q, want mise install best-effort step", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), fmt.Sprintf(`INSTALL_TIMEOUT_SECS=%d`, vmRunToolingInstallTimeoutSeconds)) {
|
||||
t.Fatalf("uploadData = %q, want deterministic install timeout", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `deterministic install: go@1.25.0 (go.mod)`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic install log", string(fakeClient.uploadData))
|
||||
t.Fatalf("uploadData = %q, want repo mise install step", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" reshim`) {
|
||||
t.Fatalf("uploadData = %q, want deterministic reshim step", string(fakeClient.uploadData))
|
||||
if strings.Contains(string(fakeClient.uploadData), `opencode run`) {
|
||||
t.Fatalf("uploadData = %q, want no opencode harness run", string(fakeClient.uploadData))
|
||||
}
|
||||
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
|
||||
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
|
||||
|
|
@ -1419,33 +1403,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
|||
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
|
||||
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
|
||||
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `find "$DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +`) {
|
||||
t.Fatalf("script = %q, want guest worktree reset", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
|
||||
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
|
||||
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
|
||||
}
|
||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
|
||||
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
|
||||
}
|
||||
if fakeClient.streamSourceDir != repoRoot {
|
||||
t.Fatalf("streamSourceDir = %q, want %q", fakeClient.streamSourceDir, repoRoot)
|
||||
}
|
||||
if !reflect.DeepEqual(fakeClient.streamEntries, spec.OverlayPaths) {
|
||||
t.Fatalf("streamEntries = %v, want %v", fakeClient.streamEntries, spec.OverlayPaths)
|
||||
}
|
||||
if fakeClient.streamCommand != "tar -o -C '/root/repo' --strip-components=1 -xf -" {
|
||||
t.Fatalf("streamCommand = %q", fakeClient.streamCommand)
|
||||
}
|
||||
wantAttach := []string{"attach", "--dir", "/root/repo", "http://172.16.0.2:4096"}
|
||||
if !reflect.DeepEqual(attachArgs, wantAttach) {
|
||||
t.Fatalf("attachArgs = %v, want %v", attachArgs, wantAttach)
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"VM ready.",
|
||||
"Name: devbox",
|
||||
"Host: devbox.vm",
|
||||
"Repo: /root/repo",
|
||||
"banger vm ssh devbox",
|
||||
"opencode attach http://devbox.vm:4096 --dir /root/repo",
|
||||
"banger vm acp devbox",
|
||||
`banger vm ssh devbox -- "cd /root/repo && claude"`,
|
||||
`banger vm ssh devbox -- "cd /root/repo && pi"`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stdout = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
if !fakeClient.closed {
|
||||
t.Fatal("guest client should be closed")
|
||||
|
|
@ -1459,8 +1431,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
|
|
@ -1468,8 +1439,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1509,20 +1479,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||
return t.TempDir(), func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
return nil
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&stdout,
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
|
|
@ -1533,19 +1501,19 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
|||
|
||||
output := stderr.String()
|
||||
for _, want := range []string{
|
||||
"[vm run] preparing guest workspace",
|
||||
"[vm run] waiting for guest ssh",
|
||||
"[vm run] preparing shallow repo",
|
||||
"[vm run] copying repo metadata to guest",
|
||||
"[vm run] preparing guest checkout",
|
||||
"[vm run] overlaying host working tree",
|
||||
"[vm run] starting tooling harness",
|
||||
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||
"[vm run] attaching opencode",
|
||||
"[vm run] starting guest tooling bootstrap",
|
||||
"[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||
"[vm run] printing next steps",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("stderr = %q, want %q", output, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "[vm run] attaching opencode") {
|
||||
t.Fatalf("stderr = %q, want no auto-attach progress", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||
|
|
@ -1555,8 +1523,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
|
|
@ -1564,8 +1531,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
|
|
@ -1597,22 +1563,18 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||
return t.TempDir(), func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
attachCalled := false
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
attachCalled = true
|
||||
return nil
|
||||
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&stdout,
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
|
|
@ -1620,147 +1582,38 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
}
|
||||
if !attachCalled {
|
||||
t.Fatal("opencode attach should still run when tooling harness launch fails")
|
||||
if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") {
|
||||
t.Fatalf("stderr = %q, want tooling bootstrap warning", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
|
||||
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
|
||||
repoRoot := t.TempDir()
|
||||
|
||||
origBegin := vmCreateBeginFunc
|
||||
origStatus := vmCreateStatusFunc
|
||||
origCancel := vmCreateCancelFunc
|
||||
origWaitForSSH := guestWaitForSSHFunc
|
||||
origGuestDial := guestDialFunc
|
||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||
origOpencodeExec := opencodeExecFunc
|
||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
||||
origSSHExec := sshExecFunc
|
||||
t.Cleanup(func() {
|
||||
vmCreateBeginFunc = origBegin
|
||||
vmCreateStatusFunc = origStatus
|
||||
vmCreateCancelFunc = origCancel
|
||||
guestWaitForSSHFunc = origWaitForSSH
|
||||
guestDialFunc = origGuestDial
|
||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||
opencodeExecFunc = origOpencodeExec
|
||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
||||
sshExecFunc = origSSHExec
|
||||
})
|
||||
|
||||
vm := model.VMRecord{
|
||||
ID: "vm-id",
|
||||
Name: "devbox",
|
||||
Runtime: model.VMRuntime{
|
||||
State: model.VMStateRunning,
|
||||
GuestIP: "172.16.0.2",
|
||||
},
|
||||
}
|
||||
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
||||
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
|
||||
}
|
||||
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
|
||||
t.Fatal("vmCreateStatusFunc should not be called")
|
||||
return api.VMCreateStatusResult{}, nil
|
||||
}
|
||||
vmCreateCancelFunc = func(context.Context, string, string) error {
|
||||
t.Fatal("vmCreateCancelFunc should not be called")
|
||||
return nil
|
||||
}
|
||||
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
|
||||
return &testVMRunGuestClient{}, nil
|
||||
}
|
||||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||
return t.TempDir(), func() {}, nil
|
||||
}
|
||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
|
||||
return nil
|
||||
}
|
||||
var sshArgs []string
|
||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
||||
sshArgs = append([]string(nil), args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
err := runVMRun(
|
||||
context.Background(),
|
||||
"/tmp/bangerd.sock",
|
||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||
strings.NewReader(""),
|
||||
&bytes.Buffer{},
|
||||
&stderr,
|
||||
api.VMCreateParams{Name: "devbox"},
|
||||
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runVMRun: %v", err)
|
||||
}
|
||||
if len(sshArgs) < 3 {
|
||||
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
|
||||
}
|
||||
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
|
||||
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
|
||||
}
|
||||
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
|
||||
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
|
||||
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
|
||||
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
|
||||
t.Fatal("expected attach help output to be recognized")
|
||||
}
|
||||
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
|
||||
t.Fatal("unexpected attach support for top-level help output")
|
||||
if !strings.Contains(stdout.String(), "VM ready.") {
|
||||
t.Fatalf("stdout = %q, want next steps summary", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
|
||||
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
RepoManagedTools: []string{"node"},
|
||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
|
||||
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
|
||||
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
|
||||
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
|
||||
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
|
||||
`repo-managed mise tools: node`,
|
||||
`run_best_effort "$MISE_BIN" install`,
|
||||
`deterministic install: go@1.25.0 (go.mod)`,
|
||||
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
|
||||
`deterministic skip: python (no .python-version)`,
|
||||
`run_best_effort "$MISE_BIN" reshim`,
|
||||
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
|
||||
`command timed out after ${timeout_secs}s: $*`,
|
||||
`tooling prompt file missing: $PROMPT_FILE`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("script = %q, want %q", script, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
|
||||
for _, unwanted := range []string{`opencode run`, `PROMPT_FILE=`, `--format json`, `mimo-v2-pro-free`} {
|
||||
if strings.Contains(script, unwanted) {
|
||||
t.Fatalf("script = %q, want no %q", script, unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
|
|
@ -2065,14 +1918,16 @@ func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteC
|
|||
|
||||
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
|
||||
c.runScriptCalls++
|
||||
switch c.runScriptCalls {
|
||||
case 1:
|
||||
if c.runScriptCalls == 1 {
|
||||
c.script = script
|
||||
return c.checkoutErr
|
||||
default:
|
||||
c.launchScript = script
|
||||
if c.checkoutErr != nil {
|
||||
return c.checkoutErr
|
||||
}
|
||||
return c.launchErr
|
||||
}
|
||||
c.launchScript = script
|
||||
return c.launchErr
|
||||
}
|
||||
|
||||
func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
|
@ -24,6 +25,16 @@ type Client struct {
|
|||
client *ssh.Client
|
||||
}
|
||||
|
||||
type StreamSession struct {
|
||||
client *Client
|
||||
session *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
stdout io.Reader
|
||||
stderr io.Reader
|
||||
waitCh chan error
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func WaitForSSH(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
|
|
@ -109,6 +120,116 @@ func (c *Client) StreamTarEntries(ctx context.Context, sourceDir string, entries
|
|||
return errors.Join(runErr, tarErr)
|
||||
}
|
||||
|
||||
func (c *Client) StartCommand(ctx context.Context, command string) (*StreamSession, error) {
|
||||
if c == nil || c.client == nil {
|
||||
return nil, fmt.Errorf("ssh client is not connected")
|
||||
}
|
||||
session, err := c.client.NewSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, err
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = session.Close()
|
||||
_ = c.client.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
if err := session.Start(command); err != nil {
|
||||
close(done)
|
||||
_ = session.Close()
|
||||
return nil, err
|
||||
}
|
||||
stream := &StreamSession{
|
||||
client: c,
|
||||
session: session,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
waitCh: make(chan error, 1),
|
||||
}
|
||||
go func() {
|
||||
err := session.Wait()
|
||||
close(done)
|
||||
stream.waitCh <- err
|
||||
close(stream.waitCh)
|
||||
}()
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (s *StreamSession) Stdin() io.WriteCloser {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.stdin
|
||||
}
|
||||
|
||||
func (s *StreamSession) Stdout() io.Reader {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.stdout
|
||||
}
|
||||
|
||||
func (s *StreamSession) Stderr() io.Reader {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.stderr
|
||||
}
|
||||
|
||||
func (s *StreamSession) Wait() error {
|
||||
if s == nil || s.waitCh == nil {
|
||||
return nil
|
||||
}
|
||||
err, ok := <-s.waitCh
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *StreamSession) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
s.closeOnce.Do(func() {
|
||||
err = errors.Join(
|
||||
func() error {
|
||||
if s.session != nil {
|
||||
return s.session.Close()
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
func() error {
|
||||
if s.client != nil {
|
||||
return s.client.Close()
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) runSession(ctx context.Context, command string, stdin io.Reader, logWriter io.Writer) error {
|
||||
if c == nil || c.client == nil {
|
||||
return fmt.Errorf("ssh client is not connected")
|
||||
|
|
|
|||
|
|
@ -34,6 +34,23 @@ const (
|
|||
VMStateError VMState = "error"
|
||||
)
|
||||
|
||||
type GuestSessionStatus string
|
||||
|
||||
const (
|
||||
GuestSessionStatusStarting GuestSessionStatus = "starting"
|
||||
GuestSessionStatusRunning GuestSessionStatus = "running"
|
||||
GuestSessionStatusExited GuestSessionStatus = "exited"
|
||||
GuestSessionStatusFailed GuestSessionStatus = "failed"
|
||||
GuestSessionStatusStopping GuestSessionStatus = "stopping"
|
||||
)
|
||||
|
||||
type GuestSessionStdinMode string
|
||||
|
||||
const (
|
||||
GuestSessionStdinClosed GuestSessionStdinMode = "closed"
|
||||
GuestSessionStdinPipe GuestSessionStdinMode = "pipe"
|
||||
)
|
||||
|
||||
type DaemonConfig struct {
|
||||
LogLevel string
|
||||
WebListenAddr string
|
||||
|
|
@ -148,6 +165,60 @@ type ImageBuildRequest struct {
|
|||
Docker bool
|
||||
}
|
||||
|
||||
type GuestSession struct {
|
||||
ID string `json:"id"`
|
||||
VMID string `json:"vm_id"`
|
||||
Name string `json:"name"`
|
||||
Backend string `json:"backend"`
|
||||
AttachBackend string `json:"attach_backend,omitempty"`
|
||||
AttachMode string `json:"attach_mode,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
CWD string `json:"cwd,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
StdinMode GuestSessionStdinMode `json:"stdin_mode,omitempty"`
|
||||
Status GuestSessionStatus `json:"status"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
GuestPID int `json:"guest_pid,omitempty"`
|
||||
GuestStateDir string `json:"guest_state_dir,omitempty"`
|
||||
StdoutLogPath string `json:"stdout_log_path,omitempty"`
|
||||
StderrLogPath string `json:"stderr_log_path,omitempty"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Attachable bool `json:"attachable"`
|
||||
Reattachable bool `json:"reattachable"`
|
||||
LaunchStage string `json:"launch_stage,omitempty"`
|
||||
LaunchMessage string `json:"launch_message,omitempty"`
|
||||
LaunchRawLog string `json:"launch_raw_log,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
EndedAt time.Time `json:"ended_at,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspacePrepareMode string
|
||||
|
||||
const (
|
||||
WorkspacePrepareModeShallowOverlay WorkspacePrepareMode = "shallow_overlay"
|
||||
WorkspacePrepareModeFullCopy WorkspacePrepareMode = "full_copy"
|
||||
WorkspacePrepareModeMetadataOnly WorkspacePrepareMode = "metadata_only"
|
||||
)
|
||||
|
||||
type WorkspacePrepareResult struct {
|
||||
VMID string `json:"vm_id"`
|
||||
SourcePath string `json:"source_path"`
|
||||
RepoRoot string `json:"repo_root"`
|
||||
RepoName string `json:"repo_name"`
|
||||
GuestPath string `json:"guest_path"`
|
||||
Mode WorkspacePrepareMode `json:"mode"`
|
||||
ReadOnly bool `json:"readonly"`
|
||||
HeadCommit string `json:"head_commit,omitempty"`
|
||||
CurrentBranch string `json:"current_branch,omitempty"`
|
||||
BranchName string `json:"branch_name,omitempty"`
|
||||
BaseCommit string `json:"base_commit,omitempty"`
|
||||
PreparedAt time.Time `json:"prepared_at"`
|
||||
}
|
||||
|
||||
func Now() time.Time {
|
||||
return time.Now().UTC().Truncate(time.Second)
|
||||
}
|
||||
|
|
|
|||
76
internal/sessionstream/sessionstream.go
Normal file
76
internal/sessionstream/sessionstream.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package sessionstream
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelStdin byte = 0x01
|
||||
ChannelStdout byte = 0x02
|
||||
ChannelStderr byte = 0x03
|
||||
ChannelControl byte = 0x04
|
||||
FormatV1 = "stdio_mux_v1"
|
||||
)
|
||||
|
||||
type ControlMessage struct {
|
||||
Type string `json:"type"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func WriteFrame(w io.Writer, channel byte, payload []byte) error {
|
||||
var header [5]byte
|
||||
header[0] = channel
|
||||
binary.BigEndian.PutUint32(header[1:], uint32(len(payload)))
|
||||
if _, err := w.Write(header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func ReadFrame(r io.Reader) (byte, []byte, error) {
|
||||
var header [5]byte
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header[1:])
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return header[0], payload, nil
|
||||
}
|
||||
|
||||
func WriteControl(w io.Writer, message ControlMessage) error {
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFrame(w, ChannelControl, payload)
|
||||
}
|
||||
|
||||
func ReadControl(payload []byte) (ControlMessage, error) {
|
||||
var message ControlMessage
|
||||
if err := json.Unmarshal(payload, &message); err != nil {
|
||||
return ControlMessage{}, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func ReadNextControl(r io.Reader) (ControlMessage, error) {
|
||||
channel, payload, err := ReadFrame(r)
|
||||
if err != nil {
|
||||
return ControlMessage{}, err
|
||||
}
|
||||
if channel != ChannelControl {
|
||||
return ControlMessage{}, fmt.Errorf("unexpected channel %d", channel)
|
||||
}
|
||||
return ReadControl(payload)
|
||||
}
|
||||
|
|
@ -99,6 +99,32 @@ func (s *Store) migrate() error {
|
|||
stats_json TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
|
||||
);`,
|
||||
`CREATE TABLE IF NOT EXISTS guest_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
vm_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
args_json TEXT NOT NULL DEFAULT '[]',
|
||||
cwd TEXT,
|
||||
env_json TEXT NOT NULL DEFAULT '{}',
|
||||
stdin_mode TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER,
|
||||
guest_pid INTEGER NOT NULL DEFAULT 0,
|
||||
guest_state_dir TEXT,
|
||||
stdout_log_path TEXT,
|
||||
stderr_log_path TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_error TEXT,
|
||||
attachable INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
UNIQUE(vm_id, name),
|
||||
FOREIGN KEY(vm_id) REFERENCES vms(id) ON DELETE CASCADE
|
||||
);`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
|
|
@ -111,6 +137,18 @@ func (s *Store) migrate() error {
|
|||
if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, spec := range []struct{ table, column, typ string }{
|
||||
{"guest_sessions", "attach_backend", "TEXT"},
|
||||
{"guest_sessions", "attach_mode", "TEXT"},
|
||||
{"guest_sessions", "reattachable", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"guest_sessions", "launch_stage", "TEXT"},
|
||||
{"guest_sessions", "launch_message", "TEXT"},
|
||||
{"guest_sessions", "launch_raw_log", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumnExists(s.db, spec.table, spec.column, spec.typ); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +336,122 @@ func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.
|
|||
return vms, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertGuestSession(ctx context.Context, session model.GuestSession) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
argsJSON, err := json.Marshal(session.Args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envJSON, err := json.Marshal(session.Env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tagsJSON, err := json.Marshal(session.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
const query = `
|
||||
INSERT INTO guest_sessions (
|
||||
id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
|
||||
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
|
||||
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
|
||||
created_at, started_at, updated_at, ended_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
vm_id=excluded.vm_id,
|
||||
name=excluded.name,
|
||||
backend=excluded.backend,
|
||||
attach_backend=excluded.attach_backend,
|
||||
attach_mode=excluded.attach_mode,
|
||||
command=excluded.command,
|
||||
args_json=excluded.args_json,
|
||||
cwd=excluded.cwd,
|
||||
env_json=excluded.env_json,
|
||||
stdin_mode=excluded.stdin_mode,
|
||||
status=excluded.status,
|
||||
exit_code=excluded.exit_code,
|
||||
guest_pid=excluded.guest_pid,
|
||||
guest_state_dir=excluded.guest_state_dir,
|
||||
stdout_log_path=excluded.stdout_log_path,
|
||||
stderr_log_path=excluded.stderr_log_path,
|
||||
tags_json=excluded.tags_json,
|
||||
last_error=excluded.last_error,
|
||||
attachable=excluded.attachable,
|
||||
reattachable=excluded.reattachable,
|
||||
launch_stage=excluded.launch_stage,
|
||||
launch_message=excluded.launch_message,
|
||||
launch_raw_log=excluded.launch_raw_log,
|
||||
started_at=excluded.started_at,
|
||||
updated_at=excluded.updated_at,
|
||||
ended_at=excluded.ended_at`
|
||||
_, err = s.db.ExecContext(ctx, query,
|
||||
session.ID,
|
||||
session.VMID,
|
||||
session.Name,
|
||||
session.Backend,
|
||||
session.AttachBackend,
|
||||
session.AttachMode,
|
||||
session.Command,
|
||||
string(argsJSON),
|
||||
session.CWD,
|
||||
string(envJSON),
|
||||
string(session.StdinMode),
|
||||
string(session.Status),
|
||||
nullableInt(session.ExitCode),
|
||||
session.GuestPID,
|
||||
session.GuestStateDir,
|
||||
session.StdoutLogPath,
|
||||
session.StderrLogPath,
|
||||
string(tagsJSON),
|
||||
session.LastError,
|
||||
boolToInt(session.Attachable),
|
||||
boolToInt(session.Reattachable),
|
||||
session.LaunchStage,
|
||||
session.LaunchMessage,
|
||||
session.LaunchRawLog,
|
||||
session.CreatedAt.Format(time.RFC3339),
|
||||
nullableTimeString(session.StartedAt),
|
||||
session.UpdatedAt.Format(time.RFC3339),
|
||||
nullableTimeString(session.EndedAt),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetGuestSessionByID(ctx context.Context, id string) (model.GuestSession, error) {
|
||||
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE id = ?", id)
|
||||
return scanGuestSessionRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) {
|
||||
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? AND (id = ? OR name = ?)", vmID, idOrName, idOrName)
|
||||
return scanGuestSessionRow(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListGuestSessionsByVM(ctx context.Context, vmID string) ([]model.GuestSession, error) {
|
||||
rows, err := s.db.QueryContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? ORDER BY created_at ASC", vmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var sessions []model.GuestSession
|
||||
for rows.Next() {
|
||||
session, err := scanGuestSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGuestSession(ctx context.Context, id string) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
_, err := s.db.ExecContext(ctx, "DELETE FROM guest_sessions WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
|
||||
used := map[string]struct{}{}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
|
||||
|
|
@ -467,3 +621,124 @@ func boolToInt(value bool) int {
|
|||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const guestSessionSelectSQL = `
|
||||
SELECT id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
|
||||
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
|
||||
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
|
||||
created_at, started_at, updated_at, ended_at
|
||||
FROM guest_sessions`
|
||||
|
||||
func scanGuestSession(rows scanner) (model.GuestSession, error) {
|
||||
return scanGuestSessionRow(rows)
|
||||
}
|
||||
|
||||
func scanGuestSessionRow(row scanner) (model.GuestSession, error) {
|
||||
var session model.GuestSession
|
||||
var (
|
||||
argsJSON string
|
||||
envJSON string
|
||||
tagsJSON string
|
||||
stdinMode string
|
||||
status string
|
||||
exitCode sql.NullInt64
|
||||
startedAt sql.NullString
|
||||
endedAt sql.NullString
|
||||
attachable int
|
||||
reattachable int
|
||||
createdRaw string
|
||||
updatedRaw string
|
||||
)
|
||||
err := row.Scan(
|
||||
&session.ID,
|
||||
&session.VMID,
|
||||
&session.Name,
|
||||
&session.Backend,
|
||||
&session.AttachBackend,
|
||||
&session.AttachMode,
|
||||
&session.Command,
|
||||
&argsJSON,
|
||||
&session.CWD,
|
||||
&envJSON,
|
||||
&stdinMode,
|
||||
&status,
|
||||
&exitCode,
|
||||
&session.GuestPID,
|
||||
&session.GuestStateDir,
|
||||
&session.StdoutLogPath,
|
||||
&session.StderrLogPath,
|
||||
&tagsJSON,
|
||||
&session.LastError,
|
||||
&attachable,
|
||||
&reattachable,
|
||||
&session.LaunchStage,
|
||||
&session.LaunchMessage,
|
||||
&session.LaunchRawLog,
|
||||
&createdRaw,
|
||||
&startedAt,
|
||||
&updatedRaw,
|
||||
&endedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return session, err
|
||||
}
|
||||
session.StdinMode = model.GuestSessionStdinMode(stdinMode)
|
||||
session.Status = model.GuestSessionStatus(status)
|
||||
session.Attachable = attachable == 1
|
||||
session.Reattachable = reattachable == 1
|
||||
if argsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(argsJSON), &session.Args); err != nil {
|
||||
return session, err
|
||||
}
|
||||
}
|
||||
if envJSON != "" {
|
||||
if err := json.Unmarshal([]byte(envJSON), &session.Env); err != nil {
|
||||
return session, err
|
||||
}
|
||||
}
|
||||
if tagsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &session.Tags); err != nil {
|
||||
return session, err
|
||||
}
|
||||
}
|
||||
if exitCode.Valid {
|
||||
value := int(exitCode.Int64)
|
||||
session.ExitCode = &value
|
||||
}
|
||||
var parseErr error
|
||||
session.CreatedAt, parseErr = time.Parse(time.RFC3339, createdRaw)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
session.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedRaw)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
if startedAt.Valid && startedAt.String != "" {
|
||||
session.StartedAt, parseErr = time.Parse(time.RFC3339, startedAt.String)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
}
|
||||
if endedAt.Valid && endedAt.String != "" {
|
||||
session.EndedAt, parseErr = time.Parse(time.RFC3339, endedAt.String)
|
||||
if parseErr != nil {
|
||||
return session, parseErr
|
||||
}
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func nullableTimeString(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func nullableInt(value *int) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue