Remove opencode package + vm acp command (dead code)
The `internal/opencode` package and the `opencodeCapability` that
consumed it were hard-wired to wait for opencode on guest port 4096
when an image shipped an initrd. After the prune commits (void /
alpine / customize.sh / image build all removed), nothing banger
produces today carries an initrd, so the capability's wait path was
unreachable: every startup short-circuited to the "direct-boot, skip
opencode" branch.
Same logic for `banger vm acp`: it SSHes to `opencode acp --cwd
<path>`, a binary the golden image no longer ships. Users who run
their own image with opencode can still invoke
`ssh vm -- opencode acp --cwd /root/repo` directly — no banger
scaffolding required.
Removed:
- internal/opencode/ (whole package, 255 LOC incl. tests)
- internal/daemon/opencode.go (opencodeCapability)
- cli `vm acp` command + its helpers (runVMACP, sshACPCommandArgs,
vmACPRemoteCommand) + their tests
- The opencodeCapability{} entry in registeredCapabilities() plus
the test that pinned its presence
- `wait_opencode` progress-stage label from the vm-create renderer
- Stale mentions in daemon/doc.go, README, and webui test fixtures
~480 lines gone, 12 added. `banger/internal` is now 25 packages
instead of 26.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0933deaeb1
commit
b5c13e3938
10 changed files with 12 additions and 482 deletions
11
README.md
11
README.md
|
|
@ -131,9 +131,6 @@ banger vm session logs <vm> planner --stream stderr
|
||||||
banger vm session stop <vm> planner
|
banger vm session stop <vm> planner
|
||||||
```
|
```
|
||||||
|
|
||||||
For ACP-aware host tooling: `banger vm acp <vm>` bridges stdio to
|
|
||||||
guest `opencode acp` over SSH.
|
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Config lives at `~/.config/banger/config.toml`. All keys optional.
|
Config lives at `~/.config/banger/config.toml`. All keys optional.
|
||||||
|
|
@ -159,14 +156,14 @@ Host → guest file/directory copies, declared per-user in
|
||||||
`~/.config/banger/config.toml`:
|
`~/.config/banger/config.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[file_sync]]
|
|
||||||
host = "~/.local/share/opencode/auth.json"
|
|
||||||
guest = "~/.local/share/opencode/auth.json"
|
|
||||||
|
|
||||||
[[file_sync]]
|
[[file_sync]]
|
||||||
host = "~/.aws" # whole directory, recursive
|
host = "~/.aws" # whole directory, recursive
|
||||||
guest = "~/.aws"
|
guest = "~/.aws"
|
||||||
|
|
||||||
|
[[file_sync]]
|
||||||
|
host = "~/.config/gh/hosts.yml"
|
||||||
|
guest = "~/.config/gh/hosts.yml"
|
||||||
|
|
||||||
[[file_sync]]
|
[[file_sync]]
|
||||||
host = "~/bin/my-script"
|
host = "~/bin/my-script"
|
||||||
guest = "~/bin/my-script"
|
guest = "~/bin/my-script"
|
||||||
|
|
|
||||||
|
|
@ -736,7 +736,6 @@ func newVMCommand() *cobra.Command {
|
||||||
newVMActionCommand("delete", "Delete a VM", "vm.delete"),
|
newVMActionCommand("delete", "Delete a VM", "vm.delete"),
|
||||||
newVMSetCommand(),
|
newVMSetCommand(),
|
||||||
newVMSSHCommand(),
|
newVMSSHCommand(),
|
||||||
newVMACPCommand(),
|
|
||||||
newVMWorkspaceCommand(),
|
newVMWorkspaceCommand(),
|
||||||
newVMSessionCommand(),
|
newVMSessionCommand(),
|
||||||
newVMLogsCommand(),
|
newVMLogsCommand(),
|
||||||
|
|
@ -1140,27 +1139,6 @@ func newVMSSHCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVMACPCommand() *cobra.Command {
|
|
||||||
var cwd string
|
|
||||||
cmd := &cobra.Command{
|
|
||||||
Use: "acp <id-or-name>",
|
|
||||||
Short: "Bridge local stdio to guest opencode acp over SSH",
|
|
||||||
Args: exactArgsUsage(1, "usage: banger vm acp [--cwd PATH] <id-or-name>"),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateSSHPrereqs(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return runVMACP(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], cwd)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVar(&cwd, "cwd", "", "guest working directory for opencode acp")
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func newVMWorkspaceCommand() *cobra.Command {
|
func newVMWorkspaceCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "workspace",
|
Use: "workspace",
|
||||||
|
|
@ -2497,18 +2475,6 @@ func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reade
|
||||||
return sshErr
|
return sshErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func runVMACP(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, idOrName, cwd string) error {
|
|
||||||
result, err := vmSSHFunc(ctx, socketPath, idOrName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sshArgs, err := sshACPCommandArgs(cfg, result.GuestIP, vmACPRemoteCommand(cwd))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return sshExecFunc(ctx, stdin, stdout, stderr, sshArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldCheckSSHReminder(err error) bool {
|
func shouldCheckSSHReminder(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true
|
return true
|
||||||
|
|
@ -2544,30 +2510,6 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s
|
||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sshACPCommandArgs(cfg model.DaemonConfig, guestIP, remoteCommand string) ([]string, error) {
|
|
||||||
if guestIP == "" {
|
|
||||||
return nil, errors.New("vm has no guest IP")
|
|
||||||
}
|
|
||||||
args := []string{"-T", "-F", "/dev/null"}
|
|
||||||
if cfg.SSHKeyPath != "" {
|
|
||||||
args = append(args, "-i", cfg.SSHKeyPath)
|
|
||||||
}
|
|
||||||
args = append(
|
|
||||||
args,
|
|
||||||
"-o", "IdentitiesOnly=yes",
|
|
||||||
"-o", "BatchMode=yes",
|
|
||||||
"-o", "PreferredAuthentications=publickey",
|
|
||||||
"-o", "PasswordAuthentication=no",
|
|
||||||
"-o", "KbdInteractiveAuthentication=no",
|
|
||||||
"-o", "StrictHostKeyChecking=no",
|
|
||||||
"-o", "UserKnownHostsFile=/dev/null",
|
|
||||||
"-o", "LogLevel=ERROR",
|
|
||||||
"root@"+guestIP,
|
|
||||||
"bash", "-lc", remoteCommand,
|
|
||||||
)
|
|
||||||
return args, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
||||||
checks := system.NewPreflight()
|
checks := system.NewPreflight()
|
||||||
checks.RequireCommand("ssh", "install openssh-client")
|
checks.RequireCommand("ssh", "install openssh-client")
|
||||||
|
|
@ -3175,24 +3117,6 @@ func printVMRunWarning(out io.Writer, detail string) {
|
||||||
_, _ = fmt.Fprintln(out, "[vm run] warning: "+detail)
|
_, _ = fmt.Fprintln(out, "[vm run] warning: "+detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func vmACPRemoteCommand(cwd string) string {
|
|
||||||
var script strings.Builder
|
|
||||||
script.WriteString("set -euo pipefail\n")
|
|
||||||
if strings.TrimSpace(cwd) != "" {
|
|
||||||
fmt.Fprintf(&script, "DIR=%s\n", shellQuote(cwd))
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&script, "REPO_DIR=%s\n", shellQuote(vmRunGuestDir()))
|
|
||||||
fmt.Fprintf(&script, "DEFAULT_DIR=%s\n", shellQuote("/root"))
|
|
||||||
script.WriteString(`if [ -d "$REPO_DIR" ]; then DIR="$REPO_DIR"; else DIR="$DEFAULT_DIR"; fi
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
script.WriteString(`cd "$DIR"
|
|
||||||
`)
|
|
||||||
script.WriteString(`exec opencode acp --cwd "$DIR"
|
|
||||||
`)
|
|
||||||
return script.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func shellQuote(value string) string {
|
func shellQuote(value string) string {
|
||||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||||
}
|
}
|
||||||
|
|
@ -3530,8 +3454,6 @@ func vmCreateStageLabel(stage string) string {
|
||||||
return "waiting for vsock agent"
|
return "waiting for vsock agent"
|
||||||
case "wait_guest_ready":
|
case "wait_guest_ready":
|
||||||
return "waiting for guest services"
|
return "waiting for guest services"
|
||||||
case "wait_opencode":
|
|
||||||
return "waiting for opencode"
|
|
||||||
case "apply_dns":
|
case "apply_dns":
|
||||||
return "publishing dns"
|
return "publishing dns"
|
||||||
case "apply_nat":
|
case "apply_nat":
|
||||||
|
|
|
||||||
|
|
@ -268,21 +268,6 @@ func TestVMRunFlagsExist(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVMACPFlagsExist(t *testing.T) {
|
|
||||||
root := NewBangerCommand()
|
|
||||||
vm, _, err := root.Find([]string{"vm"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("find vm: %v", err)
|
|
||||||
}
|
|
||||||
acp, _, err := vm.Find([]string{"acp"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("find acp: %v", err)
|
|
||||||
}
|
|
||||||
if acp.Flags().Lookup("cwd") == nil {
|
|
||||||
t.Fatal("missing flag \"cwd\"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
|
func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
|
||||||
root := NewBangerCommand()
|
root := NewBangerCommand()
|
||||||
vm, _, err := root.Find([]string{"vm"})
|
vm, _, err := root.Find([]string{"vm"})
|
||||||
|
|
@ -516,8 +501,8 @@ func TestRunVMCreatePollsUntilDone(t *testing.T) {
|
||||||
return api.VMCreateStatusResult{
|
return api.VMCreateStatusResult{
|
||||||
Operation: api.VMCreateOperation{
|
Operation: api.VMCreateOperation{
|
||||||
ID: "op-1",
|
ID: "op-1",
|
||||||
Stage: "wait_opencode",
|
Stage: "wait_vsock_agent",
|
||||||
Detail: "waiting for opencode on guest port 4096",
|
Detail: "waiting for guest vsock agent",
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -555,7 +540,7 @@ func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
||||||
|
|
||||||
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
||||||
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"})
|
||||||
renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"})
|
renderer.render(api.VMCreateOperation{Stage: "wait_vsock_agent", Detail: "waiting for guest vsock agent"})
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
|
lines := strings.Split(strings.TrimSpace(stderr.String()), "\n")
|
||||||
if len(lines) != 2 {
|
if len(lines) != 2 {
|
||||||
|
|
@ -564,7 +549,7 @@ func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) {
|
||||||
if lines[0] != "[vm create] preparing work disk: cloning work seed" {
|
if lines[0] != "[vm create] preparing work disk: cloning work seed" {
|
||||||
t.Fatalf("first line = %q", lines[0])
|
t.Fatalf("first line = %q", lines[0])
|
||||||
}
|
}
|
||||||
if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" {
|
if lines[1] != "[vm create] waiting for vsock agent: waiting for guest vsock agent" {
|
||||||
t.Fatalf("second line = %q", lines[1])
|
t.Fatalf("second line = %q", lines[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1017,98 +1002,6 @@ func TestSSHCommandArgs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunVMACPBridgesOverSSH(t *testing.T) {
|
|
||||||
origVMSSH := vmSSHFunc
|
|
||||||
origSSHExec := sshExecFunc
|
|
||||||
t.Cleanup(func() {
|
|
||||||
vmSSHFunc = origVMSSH
|
|
||||||
sshExecFunc = origSSHExec
|
|
||||||
})
|
|
||||||
|
|
||||||
vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) {
|
|
||||||
if socketPath != "/tmp/bangerd.sock" {
|
|
||||||
t.Fatalf("socketPath = %q, want /tmp/bangerd.sock", socketPath)
|
|
||||||
}
|
|
||||||
if idOrName != "devbox" {
|
|
||||||
t.Fatalf("idOrName = %q, want devbox", idOrName)
|
|
||||||
}
|
|
||||||
return api.VMSSHResult{Name: "devbox", GuestIP: "172.16.0.2"}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var gotArgs []string
|
|
||||||
var gotStdin string
|
|
||||||
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
|
|
||||||
gotArgs = append([]string(nil), args...)
|
|
||||||
data, err := io.ReadAll(stdin)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadAll(stdin): %v", err)
|
|
||||||
}
|
|
||||||
gotStdin = string(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runVMACP(
|
|
||||||
context.Background(),
|
|
||||||
"/tmp/bangerd.sock",
|
|
||||||
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
|
|
||||||
strings.NewReader("client stream"),
|
|
||||||
&bytes.Buffer{},
|
|
||||||
&bytes.Buffer{},
|
|
||||||
"devbox",
|
|
||||||
"",
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("runVMACP: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if gotStdin != "client stream" {
|
|
||||||
t.Fatalf("stdin = %q, want client stream", gotStdin)
|
|
||||||
}
|
|
||||||
joined := strings.Join(gotArgs, " ")
|
|
||||||
for _, want := range []string{
|
|
||||||
"-T",
|
|
||||||
"-F /dev/null",
|
|
||||||
"-i /tmp/id_ed25519",
|
|
||||||
"-o LogLevel=ERROR",
|
|
||||||
"root@172.16.0.2",
|
|
||||||
"bash -lc",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(joined, want) {
|
|
||||||
t.Fatalf("ssh args = %q, want %q", joined, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remoteCommand := gotArgs[len(gotArgs)-1]
|
|
||||||
if !strings.Contains(remoteCommand, `exec opencode acp --cwd "$DIR"`) {
|
|
||||||
t.Fatalf("remote command = %q, want ACP exec", remoteCommand)
|
|
||||||
}
|
|
||||||
if !strings.Contains(remoteCommand, "REPO_DIR='/root/repo'") {
|
|
||||||
t.Fatalf("remote command = %q, want repo fallback", remoteCommand)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVMACPRemoteCommandDefaultsToRepoThenRoot(t *testing.T) {
|
|
||||||
got := vmACPRemoteCommand("")
|
|
||||||
for _, want := range []string{
|
|
||||||
"REPO_DIR='/root/repo'",
|
|
||||||
"DEFAULT_DIR='/root'",
|
|
||||||
`if [ -d "$REPO_DIR" ]; then DIR="$REPO_DIR"; else DIR="$DEFAULT_DIR"; fi`,
|
|
||||||
`exec opencode acp --cwd "$DIR"`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("vmACPRemoteCommand() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVMACPRemoteCommandUsesExplicitCWD(t *testing.T) {
|
|
||||||
got := vmACPRemoteCommand("/workspace/project")
|
|
||||||
if !strings.Contains(got, "DIR='/workspace/project'") {
|
|
||||||
t.Fatalf("vmACPRemoteCommand() = %q, want explicit cwd", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "REPO_DIR=") {
|
|
||||||
t.Fatalf("vmACPRemoteCommand() = %q, want no repo fallback", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateSSHPrereqs(t *testing.T) {
|
func TestValidateSSHPrereqs(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
keyPath := filepath.Join(dir, "id_ed25519")
|
keyPath := filepath.Join(dir, "id_ed25519")
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ func (d *Daemon) registeredCapabilities() []vmCapability {
|
||||||
}
|
}
|
||||||
return []vmCapability{
|
return []vmCapability{
|
||||||
workDiskCapability{},
|
workDiskCapability{},
|
||||||
opencodeCapability{},
|
|
||||||
dnsCapability{},
|
dnsCapability{},
|
||||||
natCapability{},
|
natCapability{},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,13 +144,13 @@ func TestContributeHooksPopulateGuestAndMachineConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisteredCapabilitiesIncludeOpencode(t *testing.T) {
|
func TestRegisteredCapabilitiesInOrder(t *testing.T) {
|
||||||
d := &Daemon{}
|
d := &Daemon{}
|
||||||
var names []string
|
var names []string
|
||||||
for _, capability := range d.registeredCapabilities() {
|
for _, capability := range d.registeredCapabilities() {
|
||||||
names = append(names, capability.Name())
|
names = append(names, capability.Name())
|
||||||
}
|
}
|
||||||
want := []string{"work-disk", "opencode", "dns", "nat"}
|
want := []string{"work-disk", "dns", "nat"}
|
||||||
if !reflect.DeepEqual(names, want) {
|
if !reflect.DeepEqual(names, want) {
|
||||||
t.Fatalf("capabilities = %v, want %v", names, want)
|
t.Fatalf("capabilities = %v, want %v", names, want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@
|
||||||
// session_controller.go guestSessionController, sessionRegistry
|
// session_controller.go guestSessionController, sessionRegistry
|
||||||
// ssh_client_config.go daemon-managed SSH client key material
|
// ssh_client_config.go daemon-managed SSH client key material
|
||||||
// workspace.go ExportVMWorkspace, PrepareVMWorkspace
|
// workspace.go ExportVMWorkspace, PrepareVMWorkspace
|
||||||
// opencode.go opencode host-side helpers
|
|
||||||
//
|
//
|
||||||
// Host bootstrap (in this package):
|
// Host bootstrap (in this package):
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package daemon
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"banger/internal/model"
|
|
||||||
"banger/internal/opencode"
|
|
||||||
)
|
|
||||||
|
|
||||||
type opencodeCapability struct{}
|
|
||||||
|
|
||||||
func (opencodeCapability) Name() string { return "opencode" }
|
|
||||||
|
|
||||||
func (opencodeCapability) PostStart(ctx context.Context, d *Daemon, vm model.VMRecord, image model.Image) error {
|
|
||||||
if strings.TrimSpace(image.InitrdPath) == "" {
|
|
||||||
// Direct-boot images (OCI pulls) don't ship the opencode
|
|
||||||
// service — skip the readiness check so the VM isn't marked
|
|
||||||
// as error for lacking an opinionated add-on.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return opencode.WaitReady(ctx, d.logger, vm.Runtime.VSockPath, func(stage, detail string) {
|
|
||||||
vmCreateStage(ctx, stage, detail)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
package opencode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"banger/internal/vsockagent"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
Port = 4096
|
|
||||||
Host = "0.0.0.0"
|
|
||||||
GuestBinaryPath = "/usr/local/bin/opencode"
|
|
||||||
ShimPath = "/root/.local/share/mise/shims/opencode"
|
|
||||||
ServiceName = "banger-opencode.service"
|
|
||||||
RunitServiceName = "banger-opencode"
|
|
||||||
ReadyTimeout = 45 * time.Second
|
|
||||||
pollInterval = 200 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
func ServiceUnit() string {
|
|
||||||
return fmt.Sprintf(`[Unit]
|
|
||||||
Description=Banger opencode server
|
|
||||||
After=network.target
|
|
||||||
RequiresMountsFor=/root
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
Environment=HOME=/root
|
|
||||||
WorkingDirectory=/root
|
|
||||||
ExecStart=%s serve --hostname %s --port %d
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=1
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
`, GuestBinaryPath, Host, Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunitRunScript() string {
|
|
||||||
return fmt.Sprintf(`#!/bin/sh
|
|
||||||
set -e
|
|
||||||
export HOME=/root
|
|
||||||
cd /root
|
|
||||||
exec %s serve --hostname %s --port %d
|
|
||||||
`, GuestBinaryPath, Host, Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Ready(listeners []vsockagent.PortListener) bool {
|
|
||||||
for _, listener := range listeners {
|
|
||||||
if strings.ToLower(strings.TrimSpace(listener.Proto)) != "tcp" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if listener.Port == Port {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func WaitReady(ctx context.Context, logger *slog.Logger, socketPath string, report func(stage, detail string)) error {
|
|
||||||
return waitReady(ctx, logger, socketPath, ReadyTimeout, report)
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitReady(ctx context.Context, logger *slog.Logger, socketPath string, timeout time.Duration, report func(stage, detail string)) error {
|
|
||||||
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(pollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for {
|
|
||||||
portsCtx, portsCancel := context.WithTimeout(waitCtx, 3*time.Second)
|
|
||||||
listeners, err := vsockagent.Ports(portsCtx, logger, socketPath)
|
|
||||||
portsCancel()
|
|
||||||
if err == nil {
|
|
||||||
if Ready(listeners) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if report != nil {
|
|
||||||
report("wait_opencode", fmt.Sprintf("waiting for opencode on guest port %d", Port))
|
|
||||||
}
|
|
||||||
lastErr = fmt.Errorf("guest port %d is not listening yet", Port)
|
|
||||||
} else {
|
|
||||||
if report != nil {
|
|
||||||
report("wait_guest_ready", "waiting for guest services")
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-waitCtx.Done():
|
|
||||||
if lastErr != nil {
|
|
||||||
return fmt.Errorf("opencode server did not become ready on guest port %d: %w", Port, lastErr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("opencode server did not become ready on guest port %d before timeout", Port)
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
package opencode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"banger/internal/vsockagent"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestServiceUnitContainsExpectedExecStart(t *testing.T) {
|
|
||||||
unit := ServiceUnit()
|
|
||||||
for _, snippet := range []string{
|
|
||||||
"RequiresMountsFor=/root",
|
|
||||||
"WorkingDirectory=/root",
|
|
||||||
"Environment=HOME=/root",
|
|
||||||
"ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096",
|
|
||||||
"WantedBy=multi-user.target",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(unit, snippet) {
|
|
||||||
t.Fatalf("service unit missing snippet %q\nunit:\n%s", snippet, unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunitRunScriptContainsExpectedExec(t *testing.T) {
|
|
||||||
script := RunitRunScript()
|
|
||||||
for _, snippet := range []string{
|
|
||||||
"export HOME=/root",
|
|
||||||
"cd /root",
|
|
||||||
"exec /usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(script, snippet) {
|
|
||||||
t.Fatalf("runit script missing snippet %q\nscript:\n%s", snippet, script)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadyMatchesTCPPort(t *testing.T) {
|
|
||||||
if Ready([]vsockagent.PortListener{{Proto: "udp", Port: Port}}) {
|
|
||||||
t.Fatal("udp listener should not satisfy readiness")
|
|
||||||
}
|
|
||||||
if Ready([]vsockagent.PortListener{{Proto: "tcp", Port: 8080}}) {
|
|
||||||
t.Fatal("wrong tcp port should not satisfy readiness")
|
|
||||||
}
|
|
||||||
if !Ready([]vsockagent.PortListener{{Proto: "tcp", Port: Port}}) {
|
|
||||||
t.Fatal("tcp listener on opencode port should satisfy readiness")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWaitReadyReturnsWhenPortIsListening(t *testing.T) {
|
|
||||||
socketPath := filepath.Join(t.TempDir(), "opencode.vsock")
|
|
||||||
listener, err := net.Listen("unix", socketPath)
|
|
||||||
if err != nil {
|
|
||||||
skipIfSocketRestricted(t, err)
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = os.Remove(socketPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
serverDone := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
serverDone <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
buf := make([]byte, 512)
|
|
||||||
n, err := conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
serverDone <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got := string(buf[:n]); got != "CONNECT 42070\n" {
|
|
||||||
serverDone <- fmt.Errorf("unexpected connect message %q", got)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := conn.Write([]byte("OK 1\n")); err != nil {
|
|
||||||
serverDone <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reqBuf := make([]byte, 0, 512)
|
|
||||||
for {
|
|
||||||
n, err = conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
serverDone <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reqBuf = append(reqBuf, buf[:n]...)
|
|
||||||
if strings.Contains(string(reqBuf), "\r\n\r\n") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(reqBuf), "GET /ports HTTP/1.1\r\n") {
|
|
||||||
serverDone <- fmt.Errorf("unexpected ports payload %q", string(reqBuf))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body := []byte(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":4096}]}`)
|
|
||||||
_, err = conn.Write([]byte(fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body)))
|
|
||||||
serverDone <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := waitReady(context.Background(), nil, socketPath, time.Second, nil); err != nil {
|
|
||||||
t.Fatalf("waitReady: %v", err)
|
|
||||||
}
|
|
||||||
if err := <-serverDone; err != nil {
|
|
||||||
t.Fatalf("server: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWaitReadyReportsGuestServicesWhenPortsUnavailable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var reports []string
|
|
||||||
err := waitReady(
|
|
||||||
context.Background(),
|
|
||||||
nil,
|
|
||||||
filepath.Join(t.TempDir(), "missing.vsock"),
|
|
||||||
50*time.Millisecond,
|
|
||||||
func(stage, detail string) {
|
|
||||||
reports = append(reports, stage+":"+detail)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("waitReady() error = nil, want timeout")
|
|
||||||
}
|
|
||||||
if len(reports) == 0 {
|
|
||||||
t.Fatal("waitReady() did not report progress")
|
|
||||||
}
|
|
||||||
if got := reports[0]; got != "wait_guest_ready:waiting for guest services" {
|
|
||||||
t.Fatalf("first report = %q, want guest services wait", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func skipIfSocketRestricted(t *testing.T, err error) {
|
|
||||||
t.Helper()
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "operation not permitted") {
|
|
||||||
t.Skipf("socket creation is restricted in this environment: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -171,7 +171,7 @@ func TestVMShowPageRendersRunningActions(t *testing.T) {
|
||||||
ports: api.VMPortsResult{
|
ports: api.VMPortsResult{
|
||||||
Name: "smth",
|
Name: "smth",
|
||||||
Ports: []api.VMPort{
|
Ports: []api.VMPort{
|
||||||
{Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "opencode"},
|
{Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "devserver"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +189,7 @@ func TestVMShowPageRendersRunningActions(t *testing.T) {
|
||||||
t.Fatalf("body missing %q\n%s", want, body)
|
t.Fatalf("body missing %q\n%s", want, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, unwanted := range []string{"opencode attach", "root@172.16.0.2"} {
|
for _, unwanted := range []string{"root@172.16.0.2"} {
|
||||||
if strings.Contains(body, unwanted) {
|
if strings.Contains(body, unwanted) {
|
||||||
t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body)
|
t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue