Manage image artifacts and show VM create progress

Stop relying on ad hoc rootfs handling by adding image promotion, managed work-seed fingerprint metadata, and lazy self-healing for older managed images after the first create.

Rebuild guest images with baked SSH access, a guest NIC bootstrap, and default opencode services, and add the staged Void kernel/initramfs/modules workflow so void-exp uses a matching Void boot stack.

Replace the opaque blocking vm.create RPC with a begin/status flow that prints live stages in the CLI while still waiting for vsock health and opencode on guest port 4096.

Validate with GOCACHE=/tmp/banger-gocache go test ./... and live void-exp create/delete smoke runs.
This commit is contained in:
Thales Maciel 2026-03-21 14:48:01 -03:00
parent 9f09b0d25c
commit 30f0c0b54a
No known key found for this signature in database
GPG key ID: 33112E6833C34679
37 changed files with 2334 additions and 99 deletions

View file

@ -0,0 +1,104 @@
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 = 15 * 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_vsock_agent", "waiting for guest vsock agent")
}
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:
}
}
}

View file

@ -0,0 +1,116 @@
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 {
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)
}
}