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
46
README.md
46
README.md
|
|
@ -113,17 +113,45 @@ Create and use a VM:
|
||||||
|
|
||||||
`vm create` stays synchronous by default, but on a TTY it now shows live progress until the VM is fully ready.
|
`vm create` stays synchronous by default, but on a TTY it now shows live progress until the VM is fully ready.
|
||||||
|
|
||||||
Start a repo-backed VM session and attach `opencode` automatically:
|
Start a repo-backed VM session:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build/bin/banger vm run
|
./build/bin/banger vm run
|
||||||
./build/bin/banger vm run ../some-repo --branch feature/alpine --from HEAD
|
./build/bin/banger vm run ../some-repo --branch feature/alpine --from HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
`vm run` resolves the enclosing git repository, creates a VM, copies a git checkout plus current tracked and untracked non-ignored files into `/root/repo`, starts a best-effort guest tooling harness that inspects the repo and installs clearly-needed tools with `mise`, and then prefers a host-side `opencode attach` session when the local client supports it. Older host opencode clients fall back to starting `opencode` inside the guest over SSH. The harness runs asynchronously and logs its output inside the guest.
|
`vm run` resolves the enclosing git repository, creates a VM, copies a git checkout plus current tracked and untracked non-ignored files into `/root/repo`, starts a best-effort guest tooling bootstrap that only uses `mise`, prints next-step commands, and exits. It does not auto-attach `opencode` anymore. The bootstrap runs asynchronously and logs its output inside the guest.
|
||||||
|
|
||||||
|
After `vm run`, use one of:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/bin/banger vm ssh <vm-name>
|
||||||
|
opencode attach http://<vm-name>.vm:4096 --dir /root/repo
|
||||||
|
./build/bin/banger vm acp <vm-name>
|
||||||
|
./build/bin/banger vm ssh <vm-name> -- "cd /root/repo && claude"
|
||||||
|
./build/bin/banger vm ssh <vm-name> -- "cd /root/repo && pi"
|
||||||
|
```
|
||||||
|
|
||||||
For ACP-aware host tools, `./build/bin/banger vm acp <vm-name>` bridges stdio to guest `opencode acp` over SSH. It uses `/root/repo` when that checkout exists, otherwise `/root`, and `--cwd` lets you override the guest working directory explicitly.
|
For ACP-aware host tools, `./build/bin/banger vm acp <vm-name>` bridges stdio to guest `opencode acp` over SSH. It uses `/root/repo` when that checkout exists, otherwise `/root`, and `--cwd` lets you override the guest working directory explicitly.
|
||||||
|
|
||||||
|
If you want reusable orchestration primitives instead of the `vm run` convenience flow, use the daemon-backed workspace and session commands directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build/bin/banger vm workspace prepare <vm-name>
|
||||||
|
./build/bin/banger vm workspace prepare <vm-name> ../other-repo --guest-path /root/repo --readonly
|
||||||
|
./build/bin/banger vm session start <vm-name> --name planner --cwd /root/repo --stdin-mode pipe -- pi --mode rpc --no-session
|
||||||
|
./build/bin/banger vm session list <vm-name>
|
||||||
|
./build/bin/banger vm session attach <vm-name> planner
|
||||||
|
./build/bin/banger vm session logs <vm-name> planner --stream stderr
|
||||||
|
./build/bin/banger vm session stop <vm-name> planner
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm workspace prepare` materializes a local git checkout into a running VM. The default guest path is `/root/repo` and the default mode is a shallow metadata copy plus tracked and untracked non-ignored overlay. Repositories with git submodules must use `--mode full_copy`; the metadata-based modes still reject them.
|
||||||
|
|
||||||
|
`vm session start` creates a daemon-managed long-lived guest command. The daemon preflights that the requested guest `cwd` exists and that the main command, plus any repeated `--require-command` entries, exist in guest `PATH` before launch. Use `--stdin-mode pipe` when you need live `attach`; otherwise use the default detached mode and inspect sessions with `list`, `show`, `logs`, `stop`, and `kill`.
|
||||||
|
|
||||||
|
`vm session attach` is currently exclusive and same-host only. The daemon exposes a local Unix socket bridge using `stdio_mux_v1`, so only one active attach is allowed at a time. Pipe-mode sessions keep enough guest-side state for the daemon to rebuild that bridge after a daemon restart.
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
`bangerd` serves a local web UI by default at:
|
`bangerd` serves a local web UI by default at:
|
||||||
|
|
@ -144,15 +172,25 @@ web_listen_addr = ""
|
||||||
|
|
||||||
## Guest Services
|
## Guest Services
|
||||||
|
|
||||||
Provisioned images include:
|
Provisioned glibc-backed images include:
|
||||||
|
|
||||||
- `banger-vsock-agent`
|
- `banger-vsock-agent`
|
||||||
- guest networking bootstrap
|
- guest networking bootstrap
|
||||||
- `mise`
|
- `mise`
|
||||||
- `opencode`
|
- `opencode`
|
||||||
|
- `claude`
|
||||||
|
- `pi`
|
||||||
- a default guest `opencode` service on `0.0.0.0:4096`
|
- a default guest `opencode` service on `0.0.0.0:4096`
|
||||||
|
|
||||||
If host `~/.local/share/opencode/auth.json` exists, `banger` syncs it into the guest at `/root/.local/share/opencode/auth.json` on VM start. Changes on the host take effect after the VM is restarted.
|
Alpine currently remains `opencode`-only.
|
||||||
|
|
||||||
|
If these host auth files exist, `banger` syncs them into the guest on VM start:
|
||||||
|
|
||||||
|
- `~/.local/share/opencode/auth.json` -> `/root/.local/share/opencode/auth.json`
|
||||||
|
- `~/.claude/.credentials.json` -> `/root/.claude/.credentials.json`
|
||||||
|
- `~/.pi/agent/auth.json` -> `/root/.pi/agent/auth.json`
|
||||||
|
|
||||||
|
Changes on the host take effect after the VM is restarted. Session/history directories are not copied.
|
||||||
|
|
||||||
From the host:
|
From the host:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,73 @@ type VMPortsResult struct {
|
||||||
Ports []VMPort `json:"ports"`
|
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 {
|
type ImageBuildParams struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
FromImage string `json:"from_image,omitempty"`
|
FromImage string `json:"from_image,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import (
|
||||||
"banger/internal/model"
|
"banger/internal/model"
|
||||||
"banger/internal/paths"
|
"banger/internal/paths"
|
||||||
"banger/internal/rpc"
|
"banger/internal/rpc"
|
||||||
|
"banger/internal/sessionstream"
|
||||||
"banger/internal/system"
|
"banger/internal/system"
|
||||||
"banger/internal/toolingplan"
|
"banger/internal/toolingplan"
|
||||||
"banger/internal/vmdns"
|
"banger/internal/vmdns"
|
||||||
|
|
@ -50,15 +51,7 @@ var (
|
||||||
sshCmd.Stdin = stdin
|
sshCmd.Stdin = stdin
|
||||||
return sshCmd.Run()
|
return sshCmd.Run()
|
||||||
}
|
}
|
||||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, 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) {
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -93,6 +86,30 @@ var (
|
||||||
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
|
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
|
||||||
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
|
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 {
|
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||||
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
|
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +136,7 @@ type vmRunRepoSpec struct {
|
||||||
HeadCommit string
|
HeadCommit string
|
||||||
CurrentBranch string
|
CurrentBranch string
|
||||||
BranchName string
|
BranchName string
|
||||||
|
FromRef string
|
||||||
BaseCommit string
|
BaseCommit string
|
||||||
OriginURL string
|
OriginURL string
|
||||||
GitUserName string
|
GitUserName string
|
||||||
|
|
@ -128,22 +146,8 @@ type vmRunRepoSpec struct {
|
||||||
|
|
||||||
const vmRunShallowFetchDepth = 10
|
const vmRunShallowFetchDepth = 10
|
||||||
|
|
||||||
const vmRunToolingHarnessModel = "opencode/mimo-v2-pro-free"
|
|
||||||
const vmRunToolingHarnessTimeoutSeconds = 45
|
|
||||||
const vmRunToolingInstallTimeoutSeconds = 120
|
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 {
|
func NewBangerCommand() *cobra.Command {
|
||||||
root := &cobra.Command{
|
root := &cobra.Command{
|
||||||
Use: "banger",
|
Use: "banger",
|
||||||
|
|
@ -464,6 +468,8 @@ func newVMCommand() *cobra.Command {
|
||||||
newVMSetCommand(),
|
newVMSetCommand(),
|
||||||
newVMSSHCommand(),
|
newVMSSHCommand(),
|
||||||
newVMACPCommand(),
|
newVMACPCommand(),
|
||||||
|
newVMWorkspaceCommand(),
|
||||||
|
newVMSessionCommand(),
|
||||||
newVMLogsCommand(),
|
newVMLogsCommand(),
|
||||||
newVMStatsCommand(),
|
newVMStatsCommand(),
|
||||||
newVMPortsCommand(),
|
newVMPortsCommand(),
|
||||||
|
|
@ -485,8 +491,13 @@ func newVMRunCommand() *cobra.Command {
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "run [path]",
|
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]"),
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
|
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
|
||||||
return errors.New("--branch requires a branch name")
|
return errors.New("--branch requires a branch name")
|
||||||
|
|
@ -835,7 +846,7 @@ func newVMACPCommand() *cobra.Command {
|
||||||
var cwd string
|
var cwd string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "acp <id-or-name>",
|
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>"),
|
Args: exactArgsUsage(1, "usage: banger vm acp [--cwd PATH] <id-or-name>"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
layout, cfg, err := ensureDaemon(cmd.Context())
|
||||||
|
|
@ -852,6 +863,393 @@ func newVMACPCommand() *cobra.Command {
|
||||||
return cmd
|
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 {
|
func newVMLogsCommand() *cobra.Command {
|
||||||
var follow bool
|
var follow bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -1532,7 +1930,6 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
||||||
func validateVMRunPrereqs(cfg model.DaemonConfig) error {
|
func validateVMRunPrereqs(cfg model.DaemonConfig) error {
|
||||||
checks := system.NewPreflight()
|
checks := system.NewPreflight()
|
||||||
checks.RequireCommand("git", "install git")
|
checks.RequireCommand("git", "install git")
|
||||||
checks.RequireCommand("opencode", "install opencode")
|
|
||||||
if strings.TrimSpace(cfg.SSHKeyPath) != "" {
|
if strings.TrimSpace(cfg.SSHKeyPath) != "" {
|
||||||
checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`)
|
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
|
baseCommit := headCommit
|
||||||
|
resolvedFromRef := ""
|
||||||
branchName = strings.TrimSpace(branchName)
|
branchName = strings.TrimSpace(branchName)
|
||||||
if branchName != "" {
|
if branchName != "" {
|
||||||
fromRef = strings.TrimSpace(fromRef)
|
fromRef = strings.TrimSpace(fromRef)
|
||||||
if fromRef == "" {
|
if fromRef == "" {
|
||||||
return vmRunRepoSpec{}, errors.New("--from cannot be empty")
|
return vmRunRepoSpec{}, errors.New("--from cannot be empty")
|
||||||
}
|
}
|
||||||
|
resolvedFromRef = fromRef
|
||||||
baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vmRunRepoSpec{}, fmt.Errorf("resolve --from %q: %w", fromRef, err)
|
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,
|
HeadCommit: headCommit,
|
||||||
CurrentBranch: currentBranch,
|
CurrentBranch: currentBranch,
|
||||||
BranchName: branchName,
|
BranchName: branchName,
|
||||||
|
FromRef: resolvedFromRef,
|
||||||
BaseCommit: baseCommit,
|
BaseCommit: baseCommit,
|
||||||
OriginURL: originURL,
|
OriginURL: originURL,
|
||||||
GitUserName: gitUserName,
|
GitUserName: gitUserName,
|
||||||
|
|
@ -1733,6 +2133,17 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
|
||||||
if vmRef == "" {
|
if vmRef == "" {
|
||||||
vmRef = shortID(vm.ID)
|
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")
|
sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22")
|
||||||
progress.render("waiting for guest ssh")
|
progress.render("waiting for guest ssh")
|
||||||
if err := guestWaitForSSHFunc(ctx, sshAddress, cfg.SSHKeyPath, 250*time.Millisecond); err != nil {
|
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)
|
return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
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 {
|
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 {
|
if progress != nil {
|
||||||
return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err)
|
progress.render("printing next steps")
|
||||||
}
|
}
|
||||||
return nil
|
return printVMRunNextSteps(stdout, vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
|
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"))
|
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 {
|
func vmRunToolingHarnessLogPath(repoName string) string {
|
||||||
return filepath.ToSlash(filepath.Join("/root/.cache/banger", "vm-run-tooling-"+repoName+".log"))
|
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 {
|
func startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress.render("starting tooling harness")
|
progress.render("starting guest tooling bootstrap")
|
||||||
}
|
}
|
||||||
plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot)
|
plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot)
|
||||||
var uploadLog bytes.Buffer
|
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 {
|
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
|
var launchLog bytes.Buffer
|
||||||
if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil {
|
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 {
|
if progress != nil {
|
||||||
progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName))
|
progress.render("guest tooling log: " + vmRunToolingHarnessLogPath(spec.RepoName))
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string {
|
||||||
var script strings.Builder
|
var script strings.Builder
|
||||||
script.WriteString("set -uo pipefail\n")
|
script.WriteString("set -uo pipefail\n")
|
||||||
|
|
@ -1980,12 +2358,11 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
|
||||||
script.WriteString("}\n")
|
script.WriteString("}\n")
|
||||||
script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n")
|
script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n")
|
||||||
script.WriteString("MISE_BIN=\"$(command -v mise || true)\"\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 guest tooling bootstrap\"; exit 0; fi\n")
|
||||||
script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping tooling harness\"; exit 0; fi\n")
|
script.WriteString("log \"starting guest tooling bootstrap in $DIR\"\n")
|
||||||
script.WriteString("if [ -z \"$OPENCODE_BIN\" ]; then log \"opencode not found; skipping tooling harness\"; exit 0; fi\n")
|
if len(plan.RepoManagedTools) > 0 {
|
||||||
fmt.Fprintf(&script, "PROMPT_FILE=%s\n", shellQuote(vmRunToolingHarnessPromptPath(spec.RepoName)))
|
fmt.Fprintf(&script, "log %s\n", shellQuote("repo-managed mise tools: "+strings.Join(plan.RepoManagedTools, ", ")))
|
||||||
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 [ -f .mise.toml ] || [ -f .tool-versions ]; then\n")
|
script.WriteString("if [ -f .mise.toml ] || [ -f .tool-versions ]; then\n")
|
||||||
script.WriteString(" log \"running mise install from repo declarations\"\n")
|
script.WriteString(" log \"running mise install from repo declarations\"\n")
|
||||||
script.WriteString(" run_best_effort \"$MISE_BIN\" install\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 {
|
if len(plan.Steps) > 0 {
|
||||||
script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n")
|
script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&script, "MODEL=%s\n", shellQuote(vmRunToolingHarnessModel))
|
script.WriteString("log \"guest tooling bootstrap finished\"\n")
|
||||||
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")
|
|
||||||
return script.String()
|
return script.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2022,46 +2395,31 @@ func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
|
||||||
return script.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 {
|
func printVMRunNextSteps(out io.Writer, vm model.VMRecord) error {
|
||||||
guestIP = strings.TrimSpace(guestIP)
|
if out == nil {
|
||||||
if guestIP == "" {
|
return nil
|
||||||
return errors.New("vm has no guest IP")
|
|
||||||
}
|
}
|
||||||
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
|
vmRef := strings.TrimSpace(vm.Name)
|
||||||
if err != nil {
|
if vmRef == "" {
|
||||||
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
|
vmRef = shortID(vm.ID)
|
||||||
}
|
}
|
||||||
if supportsAttach {
|
hostRef := strings.TrimSpace(vm.Runtime.DNSName)
|
||||||
if progress != nil {
|
if hostRef == "" {
|
||||||
progress.render("attaching opencode")
|
hostRef = strings.TrimSpace(vm.Runtime.GuestIP)
|
||||||
}
|
|
||||||
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
|
|
||||||
"attach",
|
|
||||||
"--dir", guestDir,
|
|
||||||
"http://" + net.JoinHostPort(guestIP, "4096"),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if progress != nil {
|
guestDir := vmRunGuestDir()
|
||||||
progress.render("host opencode has no attach support; starting guest opencode over ssh")
|
_, err := fmt.Fprintf(out, `VM ready.
|
||||||
}
|
Name: %s
|
||||||
sshArgs, err := sshCommandArgs(cfg, guestIP, []string{"bash", "-lc", fmt.Sprintf("cd %s && exec opencode .", shellQuote(guestDir))})
|
Host: %s
|
||||||
if err != nil {
|
Repo: %s
|
||||||
return err
|
Next:
|
||||||
}
|
banger vm ssh %s
|
||||||
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
|
opencode attach http://%s:4096 --dir %s
|
||||||
}
|
banger vm acp %s
|
||||||
|
banger vm ssh %s -- "cd %s && claude"
|
||||||
func hostOpencodeAttachSupported(ctx context.Context) (bool, error) {
|
banger vm ssh %s -- "cd %s && pi"
|
||||||
output, err := hostCommandOutputFunc(ctx, "opencode", "attach", "--help")
|
`, vmRef, hostRef, guestDir, vmRef, hostRef, guestDir, vmRef, vmRef, guestDir, vmRef, guestDir)
|
||||||
if err != nil {
|
return err
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return opencodeAttachHelpOutputSupported(output), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func opencodeAttachHelpOutputSupported(output []byte) bool {
|
|
||||||
text := strings.ToLower(string(output))
|
|
||||||
return strings.Contains(text, "opencode attach")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatVMRunStepError(action string, err error, log string) error {
|
func formatVMRunStepError(action string, err error, log string) error {
|
||||||
|
|
@ -2098,6 +2456,7 @@ func (r *vmRunProgressRenderer) render(detail string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatVMRunProgress(detail string) string {
|
func formatVMRunProgress(detail string) string {
|
||||||
|
|
||||||
detail = strings.TrimSpace(detail)
|
detail = strings.TrimSpace(detail)
|
||||||
if detail == "" {
|
if detail == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -1232,7 +1232,7 @@ func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
|
||||||
repoRoot := t.TempDir()
|
repoRoot := t.TempDir()
|
||||||
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
|
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
|
||||||
|
|
||||||
|
|
@ -1243,8 +1243,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
origGuestDial := guestDialFunc
|
origGuestDial := guestDialFunc
|
||||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
|
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
|
||||||
origOpencodeExec := opencodeExecFunc
|
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
vmCreateBeginFunc = origBegin
|
vmCreateBeginFunc = origBegin
|
||||||
vmCreateStatusFunc = origStatus
|
vmCreateStatusFunc = origStatus
|
||||||
|
|
@ -1253,8 +1252,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
guestDialFunc = origGuestDial
|
guestDialFunc = origGuestDial
|
||||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
|
||||||
opencodeExecFunc = origOpencodeExec
|
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
||||||
})
|
})
|
||||||
|
|
||||||
vm := model.VMRecord{
|
vm := model.VMRecord{
|
||||||
|
|
@ -1310,22 +1308,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
}
|
}
|
||||||
return repoCopyDir, func() {}, nil
|
return repoCopyDir, func() {}, nil
|
||||||
}
|
}
|
||||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
var workspaceParams api.VMWorkspacePrepareParams
|
||||||
return true, nil
|
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 {
|
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
|
||||||
return toolingplan.Plan{
|
return toolingplan.Plan{
|
||||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
RepoManagedTools: []string{"go"},
|
||||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
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{
|
spec := vmRunRepoSpec{
|
||||||
|
SourcePath: repoRoot,
|
||||||
RepoRoot: repoRoot,
|
RepoRoot: repoRoot,
|
||||||
RepoName: "repo",
|
RepoName: "repo",
|
||||||
HeadCommit: "deadbeef",
|
HeadCommit: "deadbeef",
|
||||||
|
|
@ -1336,13 +1333,15 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
GitUserEmail: "repo@example.com",
|
GitUserEmail: "repo@example.com",
|
||||||
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
|
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
|
||||||
}
|
}
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
err := runVMRun(
|
err := runVMRun(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
"/tmp/bangerd.sock",
|
"/tmp/bangerd.sock",
|
||||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||||
strings.NewReader(""),
|
strings.NewReader(""),
|
||||||
&bytes.Buffer{},
|
&stdout,
|
||||||
&bytes.Buffer{},
|
&stderr,
|
||||||
api.VMCreateParams{Name: "devbox"},
|
api.VMCreateParams{Name: "devbox"},
|
||||||
spec,
|
spec,
|
||||||
)
|
)
|
||||||
|
|
@ -1365,29 +1364,20 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
|
||||||
if dialKeyPath != waitKeyPath {
|
if dialKeyPath != waitKeyPath {
|
||||||
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
|
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
|
||||||
}
|
}
|
||||||
if fakeClient.tarSourceDir != repoCopyDir {
|
if workspaceParams.IDOrName != "devbox" {
|
||||||
t.Fatalf("tarSourceDir = %q, want %q", fakeClient.tarSourceDir, repoCopyDir)
|
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 -" {
|
if workspaceParams.SourcePath != repoRoot {
|
||||||
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
|
t.Fatalf("workspaceParams.SourcePath = %q, want %q", workspaceParams.SourcePath, repoRoot)
|
||||||
}
|
}
|
||||||
if len(fakeClient.uploads) != 2 {
|
if workspaceParams.GuestPath != "/root/repo" {
|
||||||
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
|
t.Fatalf("workspaceParams.GuestPath = %q, want /root/repo", workspaceParams.GuestPath)
|
||||||
}
|
}
|
||||||
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
|
if workspaceParams.Mode != string(model.WorkspacePrepareModeShallowOverlay) {
|
||||||
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
|
t.Fatalf("workspaceParams.Mode = %q", workspaceParams.Mode)
|
||||||
}
|
}
|
||||||
if fakeClient.uploads[0].mode != 0o644 {
|
if len(fakeClient.uploads) != 1 {
|
||||||
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
|
t.Fatalf("uploads = %d, want 1", len(fakeClient.uploads))
|
||||||
}
|
|
||||||
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 fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
|
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
|
||||||
t.Fatalf("uploadPath = %q, want %q", 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 {
|
if fakeClient.uploadMode != 0o755 {
|
||||||
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
|
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`) {
|
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))
|
t.Fatalf("uploadData = %q, want repo mise install 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))
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
|
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))
|
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
|
if strings.Contains(string(fakeClient.uploadData), `opencode run`) {
|
||||||
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
|
t.Fatalf("uploadData = %q, want no opencode harness run", 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(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
|
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
|
||||||
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
|
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")) {
|
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
|
||||||
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
|
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
|
||||||
}
|
}
|
||||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
|
output := stdout.String()
|
||||||
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
|
for _, want := range []string{
|
||||||
}
|
"VM ready.",
|
||||||
if !strings.Contains(fakeClient.script, `find "$DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +`) {
|
"Name: devbox",
|
||||||
t.Fatalf("script = %q, want guest worktree reset", fakeClient.script)
|
"Host: devbox.vm",
|
||||||
}
|
"Repo: /root/repo",
|
||||||
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
|
"banger vm ssh devbox",
|
||||||
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
|
"opencode attach http://devbox.vm:4096 --dir /root/repo",
|
||||||
}
|
"banger vm acp devbox",
|
||||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
|
`banger vm ssh devbox -- "cd /root/repo && claude"`,
|
||||||
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
|
`banger vm ssh devbox -- "cd /root/repo && pi"`,
|
||||||
}
|
} {
|
||||||
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
|
if !strings.Contains(output, want) {
|
||||||
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
|
t.Fatalf("stdout = %q, want %q", output, want)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if !fakeClient.closed {
|
if !fakeClient.closed {
|
||||||
t.Fatal("guest client should be closed")
|
t.Fatal("guest client should be closed")
|
||||||
|
|
@ -1459,8 +1431,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
origWaitForSSH := guestWaitForSSHFunc
|
origWaitForSSH := guestWaitForSSHFunc
|
||||||
origGuestDial := guestDialFunc
|
origGuestDial := guestDialFunc
|
||||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
origOpencodeExec := opencodeExecFunc
|
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
vmCreateBeginFunc = origBegin
|
vmCreateBeginFunc = origBegin
|
||||||
vmCreateStatusFunc = origStatus
|
vmCreateStatusFunc = origStatus
|
||||||
|
|
@ -1468,8 +1439,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
guestWaitForSSHFunc = origWaitForSSH
|
guestWaitForSSHFunc = origWaitForSSH
|
||||||
guestDialFunc = origGuestDial
|
guestDialFunc = origGuestDial
|
||||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
opencodeExecFunc = origOpencodeExec
|
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
||||||
})
|
})
|
||||||
|
|
||||||
vm := model.VMRecord{
|
vm := model.VMRecord{
|
||||||
|
|
@ -1509,20 +1479,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||||
return t.TempDir(), func() {}, nil
|
return t.TempDir(), func() {}, nil
|
||||||
}
|
}
|
||||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||||
return true, nil
|
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||||
}
|
|
||||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
err := runVMRun(
|
err := runVMRun(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
"/tmp/bangerd.sock",
|
"/tmp/bangerd.sock",
|
||||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||||
strings.NewReader(""),
|
strings.NewReader(""),
|
||||||
&bytes.Buffer{},
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
api.VMCreateParams{Name: "devbox"},
|
api.VMCreateParams{Name: "devbox"},
|
||||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||||
|
|
@ -1533,19 +1501,19 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
|
|
||||||
output := stderr.String()
|
output := stderr.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
|
"[vm run] preparing guest workspace",
|
||||||
"[vm run] waiting for guest ssh",
|
"[vm run] waiting for guest ssh",
|
||||||
"[vm run] preparing shallow repo",
|
"[vm run] starting guest tooling bootstrap",
|
||||||
"[vm run] copying repo metadata to guest",
|
"[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log",
|
||||||
"[vm run] preparing guest checkout",
|
"[vm run] printing next steps",
|
||||||
"[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",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
t.Fatalf("stderr = %q, want %q", 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) {
|
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
|
|
@ -1555,8 +1523,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
origWaitForSSH := guestWaitForSSHFunc
|
origWaitForSSH := guestWaitForSSHFunc
|
||||||
origGuestDial := guestDialFunc
|
origGuestDial := guestDialFunc
|
||||||
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
|
||||||
origOpencodeExec := opencodeExecFunc
|
origVMWorkspacePrepare := vmWorkspacePrepareFunc
|
||||||
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
vmCreateBeginFunc = origBegin
|
vmCreateBeginFunc = origBegin
|
||||||
vmCreateStatusFunc = origStatus
|
vmCreateStatusFunc = origStatus
|
||||||
|
|
@ -1564,8 +1531,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
guestWaitForSSHFunc = origWaitForSSH
|
guestWaitForSSHFunc = origWaitForSSH
|
||||||
guestDialFunc = origGuestDial
|
guestDialFunc = origGuestDial
|
||||||
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
|
||||||
opencodeExecFunc = origOpencodeExec
|
vmWorkspacePrepareFunc = origVMWorkspacePrepare
|
||||||
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
|
|
||||||
})
|
})
|
||||||
|
|
||||||
vm := model.VMRecord{
|
vm := model.VMRecord{
|
||||||
|
|
@ -1597,22 +1563,18 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
|
||||||
return t.TempDir(), func() {}, nil
|
return t.TempDir(), func() {}, nil
|
||||||
}
|
}
|
||||||
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
|
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
|
||||||
return true, nil
|
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
|
||||||
}
|
|
||||||
attachCalled := false
|
|
||||||
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
||||||
attachCalled = true
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
err := runVMRun(
|
err := runVMRun(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
"/tmp/bangerd.sock",
|
"/tmp/bangerd.sock",
|
||||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
||||||
strings.NewReader(""),
|
strings.NewReader(""),
|
||||||
&bytes.Buffer{},
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
api.VMCreateParams{Name: "devbox"},
|
api.VMCreateParams{Name: "devbox"},
|
||||||
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
|
||||||
|
|
@ -1620,147 +1582,38 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runVMRun: %v", err)
|
t.Fatalf("runVMRun: %v", err)
|
||||||
}
|
}
|
||||||
if !attachCalled {
|
if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") {
|
||||||
t.Fatal("opencode attach should still run when tooling harness launch fails")
|
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") {
|
if !strings.Contains(stdout.String(), "VM ready.") {
|
||||||
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
|
t.Fatalf("stdout = %q, want next steps summary", stdout.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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
|
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
|
||||||
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
|
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
|
||||||
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
|
RepoManagedTools: []string{"node"},
|
||||||
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
|
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{
|
for _, want := range []string{
|
||||||
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
|
`repo-managed mise tools: node`,
|
||||||
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
|
|
||||||
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
|
|
||||||
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
|
|
||||||
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
|
|
||||||
`run_best_effort "$MISE_BIN" install`,
|
`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'`,
|
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
|
||||||
`deterministic skip: python (no .python-version)`,
|
`deterministic skip: python (no .python-version)`,
|
||||||
`run_best_effort "$MISE_BIN" reshim`,
|
`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) {
|
if !strings.Contains(script, want) {
|
||||||
t.Fatalf("script = %q, want %q", 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) {
|
if strings.Contains(script, unwanted) {
|
||||||
t.Fatalf("script = %q, want no %q", script, unwanted)
|
t.Fatalf("script = %q, want no %q", script, unwanted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
|
||||||
if _, err := exec.LookPath("git"); err != nil {
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
t.Skip("git not installed")
|
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 {
|
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
|
||||||
c.runScriptCalls++
|
c.runScriptCalls++
|
||||||
switch c.runScriptCalls {
|
if c.runScriptCalls == 1 {
|
||||||
case 1:
|
|
||||||
c.script = script
|
c.script = script
|
||||||
return c.checkoutErr
|
|
||||||
default:
|
|
||||||
c.launchScript = script
|
c.launchScript = script
|
||||||
|
if c.checkoutErr != nil {
|
||||||
|
return c.checkoutErr
|
||||||
|
}
|
||||||
return c.launchErr
|
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 {
|
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 {
|
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
|
||||||
return err
|
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) {
|
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {
|
||||||
|
|
|
||||||
|
|
@ -27,32 +27,33 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
layout paths.Layout
|
layout paths.Layout
|
||||||
config model.DaemonConfig
|
config model.DaemonConfig
|
||||||
store *store.Store
|
store *store.Store
|
||||||
runner system.CommandRunner
|
runner system.CommandRunner
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
createOpsMu sync.Mutex
|
createOpsMu sync.Mutex
|
||||||
createOps map[string]*vmCreateOperationState
|
createOps map[string]*vmCreateOperationState
|
||||||
imageBuildOpsMu sync.Mutex
|
imageBuildOpsMu sync.Mutex
|
||||||
imageBuildOps map[string]*imageBuildOperationState
|
imageBuildOps map[string]*imageBuildOperationState
|
||||||
vmLocksMu sync.Mutex
|
vmLocksMu sync.Mutex
|
||||||
vmLocks map[string]*sync.Mutex
|
vmLocks map[string]*sync.Mutex
|
||||||
tapPoolMu sync.Mutex
|
sessionControllers map[string]*guestSessionController
|
||||||
tapPool []string
|
tapPoolMu sync.Mutex
|
||||||
tapPoolNext int
|
tapPool []string
|
||||||
closing chan struct{}
|
tapPoolNext int
|
||||||
once sync.Once
|
closing chan struct{}
|
||||||
pid int
|
once sync.Once
|
||||||
listener net.Listener
|
pid int
|
||||||
webListener net.Listener
|
listener net.Listener
|
||||||
webServer *http.Server
|
webListener net.Listener
|
||||||
webURL string
|
webServer *http.Server
|
||||||
vmDNS *vmdns.Server
|
webURL string
|
||||||
vmCaps []vmCapability
|
vmDNS *vmdns.Server
|
||||||
imageBuild func(context.Context, imageBuildSpec) error
|
vmCaps []vmCapability
|
||||||
requestHandler func(context.Context, rpc.Request) rpc.Response
|
imageBuild func(context.Context, imageBuildSpec) error
|
||||||
|
requestHandler func(context.Context, rpc.Request) rpc.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(ctx context.Context) (d *Daemon, err error) {
|
func Open(ctx context.Context) (d *Daemon, err error) {
|
||||||
|
|
@ -125,7 +126,7 @@ func (d *Daemon) Close() error {
|
||||||
if d.webListener != nil {
|
if d.webListener != nil {
|
||||||
_ = d.webListener.Close()
|
_ = 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
|
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)
|
result, err := d.PortsVM(ctx, params.IDOrName)
|
||||||
return marshalResultOrError(result, err)
|
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":
|
case "image.list":
|
||||||
images, err := d.store.ListImages(ctx)
|
images, err := d.store.ListImages(ctx)
|
||||||
return marshalResultOrError(api.ImageListResult{Images: images}, err)
|
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 (
|
const (
|
||||||
defaultMiseVersion = "v2025.12.0"
|
defaultMiseVersion = "v2025.12.0"
|
||||||
defaultMiseInstallPath = "/usr/local/bin/mise"
|
defaultMiseInstallPath = "/usr/local/bin/mise"
|
||||||
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
|
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
|
||||||
defaultOpenCodeTool = "github:anomalyco/opencode"
|
defaultNodeTool = "node@22"
|
||||||
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
|
defaultOpenCodeTool = "github:anomalyco/opencode"
|
||||||
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
|
defaultClaudeCodePackage = "@anthropic-ai/claude-code"
|
||||||
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
|
defaultPiPackage = "@mariozechner/pi-coding-agent"
|
||||||
defaultTMUXPluginDir = "/root/.tmux/plugins"
|
defaultNPMGlobalPrefix = "/root/.local/share/banger/npm-global"
|
||||||
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
|
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
|
||||||
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
|
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
|
||||||
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
|
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 {
|
type imageBuildSpec struct {
|
||||||
|
|
@ -302,11 +306,27 @@ func buildModulesCommand(modulesBase string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendMiseSetup(script *bytes.Buffer) {
|
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, "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 use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool))
|
||||||
fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath))
|
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, "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(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("mkdir -p /etc/profile.d\n")
|
||||||
script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n")
|
script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n")
|
||||||
fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath))
|
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'",
|
"cat > /etc/systemd/system/banger-network.service <<'EOF'",
|
||||||
"systemctl enable --now banger-network.service || true",
|
"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",
|
"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' use -g 'github:anomalyco/opencode'",
|
||||||
"'/usr/local/bin/mise' reshim",
|
"'/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",
|
"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/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'",
|
"cat > /etc/profile.d/mise.sh <<'EOF'",
|
||||||
"if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then",
|
"if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then",
|
||||||
`eval "$(/usr/local/bin/mise activate bash)"`,
|
`eval "$(/usr/local/bin/mise activate bash)"`,
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,14 @@ const (
|
||||||
workDiskGitConfigRelativePath = ".gitconfig"
|
workDiskGitConfigRelativePath = ".gitconfig"
|
||||||
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
|
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
|
||||||
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
|
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
|
||||||
|
workDiskClaudeAuthDirRelativePath = ".claude"
|
||||||
|
workDiskClaudeAuthRelativePath = workDiskClaudeAuthDirRelativePath + "/.credentials.json"
|
||||||
|
workDiskPiAuthDirRelativePath = ".pi/agent"
|
||||||
|
workDiskPiAuthRelativePath = workDiskPiAuthDirRelativePath + "/auth.json"
|
||||||
hostGlobalGitIdentitySource = "git config --global"
|
hostGlobalGitIdentitySource = "git config --global"
|
||||||
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
|
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
|
||||||
|
hostClaudeAuthDefaultDisplayPath = "~/" + workDiskClaudeAuthRelativePath
|
||||||
|
hostPiAuthDefaultDisplayPath = "~/" + workDiskPiAuthRelativePath
|
||||||
)
|
)
|
||||||
|
|
||||||
type gitIdentity struct {
|
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 {
|
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 {
|
if err != nil {
|
||||||
d.warnOpencodeAuthSyncSkipped(*vm, hostOpencodeAuthDefaultDisplayPath, err)
|
warn(*vm, defaultDisplayPath, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
authData, err := os.ReadFile(hostAuthPath)
|
authData, err := os.ReadFile(hostAuthPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.warnOpencodeAuthSyncSkipped(*vm, hostAuthPath, err)
|
warn(*vm, hostAuthPath, err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
vmCreateStage(ctx, "prepare_work_disk", "syncing opencode auth")
|
runner := d.runner
|
||||||
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -989,13 +1036,13 @@ func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMR
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
authDir := filepath.Join(workMount, workDiskOpencodeAuthDirRelativePath)
|
authDir := filepath.Join(workMount, filepath.Dir(guestRelativePath))
|
||||||
if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
|
if _, err := runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1011,16 +1058,28 @@ func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMR
|
||||||
}
|
}
|
||||||
defer os.Remove(tmpPath)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveHostOpencodeAuthPath() (string, error) {
|
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()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil
|
return filepath.Join(home, relativePath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
|
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())...)
|
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) {
|
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
|
||||||
if d.logger == nil || err == nil {
|
if d.logger == nil || err == nil {
|
||||||
return
|
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) {
|
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
|
||||||
d := &Daemon{}
|
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") {
|
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"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
@ -24,6 +25,16 @@ type Client struct {
|
||||||
client *ssh.Client
|
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 {
|
func WaitForSSH(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = time.Second
|
interval = time.Second
|
||||||
|
|
@ -109,6 +120,116 @@ func (c *Client) StreamTarEntries(ctx context.Context, sourceDir string, entries
|
||||||
return errors.Join(runErr, tarErr)
|
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 {
|
func (c *Client) runSession(ctx context.Context, command string, stdin io.Reader, logWriter io.Writer) error {
|
||||||
if c == nil || c.client == nil {
|
if c == nil || c.client == nil {
|
||||||
return fmt.Errorf("ssh client is not connected")
|
return fmt.Errorf("ssh client is not connected")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,23 @@ const (
|
||||||
VMStateError VMState = "error"
|
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 {
|
type DaemonConfig struct {
|
||||||
LogLevel string
|
LogLevel string
|
||||||
WebListenAddr string
|
WebListenAddr string
|
||||||
|
|
@ -148,6 +165,60 @@ type ImageBuildRequest struct {
|
||||||
Docker bool
|
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 {
|
func Now() time.Time {
|
||||||
return time.Now().UTC().Truncate(time.Second)
|
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 '{}',
|
stats_json TEXT NOT NULL DEFAULT '{}',
|
||||||
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
|
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 {
|
for _, stmt := range stmts {
|
||||||
if _, err := s.db.Exec(stmt); err != nil {
|
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 {
|
if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,6 +336,122 @@ func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.
|
||||||
return vms, rows.Err()
|
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) {
|
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
|
||||||
used := map[string]struct{}{}
|
used := map[string]struct{}{}
|
||||||
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
|
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
|
||||||
|
|
@ -467,3 +621,124 @@ func boolToInt(value bool) int {
|
||||||
}
|
}
|
||||||
return 0
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@ INITRD=""
|
||||||
MISE_VERSION="v2025.12.0"
|
MISE_VERSION="v2025.12.0"
|
||||||
MISE_INSTALL_PATH="/usr/local/bin/mise"
|
MISE_INSTALL_PATH="/usr/local/bin/mise"
|
||||||
MISE_ACTIVATE_LINE='eval "$(/usr/local/bin/mise activate bash)"'
|
MISE_ACTIVATE_LINE='eval "$(/usr/local/bin/mise activate bash)"'
|
||||||
|
NODE_TOOL="node@22"
|
||||||
|
CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
|
||||||
|
PI_PACKAGE="@mariozechner/pi-coding-agent"
|
||||||
|
NPM_GLOBAL_PREFIX="/root/.local/share/banger/npm-global"
|
||||||
TMUX_PLUGIN_DIR="/root/.tmux/plugins"
|
TMUX_PLUGIN_DIR="/root/.tmux/plugins"
|
||||||
TMUX_RESURRECT_DIR="/root/.tmux/resurrect"
|
TMUX_RESURRECT_DIR="/root/.tmux/resurrect"
|
||||||
TMUX_TPM_REPO="https://github.com/tmux-plugins/tpm"
|
TMUX_TPM_REPO="https://github.com/tmux-plugins/tpm"
|
||||||
|
|
@ -399,14 +403,35 @@ fi
|
||||||
apt-get update
|
apt-get update
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
|
DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED}
|
DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED}
|
||||||
curl -fsSL https://mise.run | MISE_INSTALL_PATH=\"$MISE_INSTALL_PATH\" MISE_VERSION=\"$MISE_VERSION\" sh
|
curl -fsSL https://mise.run | MISE_INSTALL_PATH="$MISE_INSTALL_PATH" MISE_VERSION="$MISE_VERSION" sh
|
||||||
\"$MISE_INSTALL_PATH\" use -g github:anomalyco/opencode
|
"$MISE_INSTALL_PATH" use -g "$NODE_TOOL"
|
||||||
\"$MISE_INSTALL_PATH\" reshim
|
"$MISE_INSTALL_PATH" use -g github:anomalyco/opencode
|
||||||
|
"$MISE_INSTALL_PATH" 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
|
if [[ ! -e /root/.local/share/mise/shims/opencode ]]; then
|
||||||
echo 'opencode shim not found after mise install' >&2
|
echo 'opencode shim not found after mise install' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
mkdir -p "$NPM_GLOBAL_PREFIX"
|
||||||
|
NPM_CONFIG_PREFIX="$NPM_GLOBAL_PREFIX" /root/.local/share/mise/shims/npm install -g "$CLAUDE_CODE_PACKAGE" "$PI_PACKAGE"
|
||||||
|
if [[ ! -e "$NPM_GLOBAL_PREFIX/bin/claude" ]]; then
|
||||||
|
echo 'claude binary not found after npm install' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -e "$NPM_GLOBAL_PREFIX/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/mise/shims/opencode /usr/local/bin/opencode
|
||||||
|
ln -snf "$NPM_GLOBAL_PREFIX/bin/claude" /usr/local/bin/claude
|
||||||
|
ln -snf "$NPM_GLOBAL_PREFIX/bin/pi" /usr/local/bin/pi
|
||||||
mkdir -p /etc/profile.d
|
mkdir -p /etc/profile.d
|
||||||
cat > /etc/profile.d/mise.sh <<'MISEPROFILE'
|
cat > /etc/profile.d/mise.sh <<'MISEPROFILE'
|
||||||
if [ -n \"\${BASH_VERSION:-}\" ] && [ -x \"$MISE_INSTALL_PATH\" ]; then
|
if [ -n \"\${BASH_VERSION:-}\" ] && [ -x \"$MISE_INSTALL_PATH\" ]; then
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ EOF
|
||||||
sudo chmod 0644 "$bashrc" "$bash_profile" "$profile_prompt"
|
sudo chmod 0644 "$bashrc" "$bash_profile" "$profile_prompt"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_mise_and_opencode() {
|
install_guest_tools() {
|
||||||
local profile_mise="$ROOT_MOUNT/etc/profile.d/mise.sh"
|
local profile_mise="$ROOT_MOUNT/etc/profile.d/mise.sh"
|
||||||
|
|
||||||
sudo mkdir -p "$ROOT_MOUNT/etc/profile.d"
|
sudo mkdir -p "$ROOT_MOUNT/etc/profile.d"
|
||||||
|
|
@ -340,19 +340,37 @@ install_mise_and_opencode() {
|
||||||
sudo install -m 0644 /etc/resolv.conf "$ROOT_MOUNT/etc/resolv.conf"
|
sudo install -m 0644 /etc/resolv.conf "$ROOT_MOUNT/etc/resolv.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sudo env \
|
sudo env HOME=/root PATH=/usr/local/bin:/usr/bin:/bin chroot "$ROOT_MOUNT" /bin/bash -se <<EOF
|
||||||
HOME=/root \
|
|
||||||
PATH=/usr/local/bin:/usr/bin:/bin \
|
|
||||||
chroot "$ROOT_MOUNT" /bin/bash -se <<EOF
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -fsSL https://mise.run | MISE_INSTALL_PATH="$MISE_INSTALL_PATH" MISE_VERSION="$MISE_VERSION" sh
|
curl -fsSL https://mise.run | MISE_INSTALL_PATH="$MISE_INSTALL_PATH" MISE_VERSION="$MISE_VERSION" sh
|
||||||
|
"$MISE_INSTALL_PATH" use -g "$NODE_TOOL"
|
||||||
"$MISE_INSTALL_PATH" use -g "$OPENCODE_TOOL"
|
"$MISE_INSTALL_PATH" use -g "$OPENCODE_TOOL"
|
||||||
"$MISE_INSTALL_PATH" reshim
|
"$MISE_INSTALL_PATH" 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
|
if [[ ! -e /root/.local/share/mise/shims/opencode ]]; then
|
||||||
echo "opencode shim not found after mise install" >&2
|
echo "opencode shim not found after mise install" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
mkdir -p "$NPM_GLOBAL_PREFIX"
|
||||||
|
NPM_CONFIG_PREFIX="$NPM_GLOBAL_PREFIX" /root/.local/share/mise/shims/npm install -g "$CLAUDE_CODE_PACKAGE" "$PI_PACKAGE"
|
||||||
|
if [[ ! -e "$NPM_GLOBAL_PREFIX/bin/claude" ]]; then
|
||||||
|
echo "claude binary not found after npm install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -e "$NPM_GLOBAL_PREFIX/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/mise/shims/opencode /usr/local/bin/opencode
|
||||||
|
ln -snf "$NPM_GLOBAL_PREFIX/bin/claude" /usr/local/bin/claude
|
||||||
|
ln -snf "$NPM_GLOBAL_PREFIX/bin/pi" /usr/local/bin/pi
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat <<'EOF' | sudo tee "$profile_mise" >/dev/null
|
cat <<'EOF' | sudo tee "$profile_mise" >/dev/null
|
||||||
|
|
@ -387,7 +405,11 @@ MIRROR="https://repo-default.voidlinux.org"
|
||||||
ARCH="x86_64"
|
ARCH="x86_64"
|
||||||
MISE_VERSION="v2025.12.0"
|
MISE_VERSION="v2025.12.0"
|
||||||
MISE_INSTALL_PATH="/usr/local/bin/mise"
|
MISE_INSTALL_PATH="/usr/local/bin/mise"
|
||||||
|
NODE_TOOL="node@22"
|
||||||
OPENCODE_TOOL="github:anomalyco/opencode"
|
OPENCODE_TOOL="github:anomalyco/opencode"
|
||||||
|
CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
|
||||||
|
PI_PACKAGE="@mariozechner/pi-coding-agent"
|
||||||
|
NPM_GLOBAL_PREFIX="/root/.local/share/banger/npm-global"
|
||||||
GUESTNET_BOOTSTRAP_SCRIPT="$REPO_ROOT/internal/guestnet/assets/bootstrap.sh"
|
GUESTNET_BOOTSTRAP_SCRIPT="$REPO_ROOT/internal/guestnet/assets/bootstrap.sh"
|
||||||
GUESTNET_VOID_CORE_SERVICE="$REPO_ROOT/internal/guestnet/assets/void-core-service.sh"
|
GUESTNET_VOID_CORE_SERVICE="$REPO_ROOT/internal/guestnet/assets/void-core-service.sh"
|
||||||
MODULES_DIR=""
|
MODULES_DIR=""
|
||||||
|
|
@ -556,8 +578,8 @@ configure_docker_bootstrap
|
||||||
enable_docker_service
|
enable_docker_service
|
||||||
normalize_root_shell
|
normalize_root_shell
|
||||||
configure_root_bash_prompt
|
configure_root_bash_prompt
|
||||||
log "installing mise and opencode"
|
log "installing guest tools"
|
||||||
install_mise_and_opencode
|
install_guest_tools
|
||||||
install_opencode_service
|
install_opencode_service
|
||||||
install_root_authorized_key
|
install_root_authorized_key
|
||||||
sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname"
|
sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue