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
285 lines
9.6 KiB
Go
285 lines
9.6 KiB
Go
package model
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultBridgeName = "br-fc"
|
|
DefaultBridgeIP = "172.16.0.1"
|
|
DefaultCIDR = "24"
|
|
DefaultDNS = "1.1.1.1"
|
|
DefaultSystemOverlaySize = 8 * 1024 * 1024 * 1024
|
|
DefaultWorkDiskSize = 8 * 1024 * 1024 * 1024
|
|
DefaultMemoryMiB = 2048
|
|
DefaultVCPUCount = 2
|
|
DefaultStatsPollInterval = 10 * time.Second
|
|
DefaultStaleSweepInterval = 1 * time.Minute
|
|
DefaultMetricsPollInterval = 15 * time.Second
|
|
MaxDiskBytes int64 = 128 * 1024 * 1024 * 1024
|
|
)
|
|
|
|
type VMState string
|
|
|
|
const (
|
|
VMStateCreated VMState = "created"
|
|
VMStateRunning VMState = "running"
|
|
VMStateStopped VMState = "stopped"
|
|
VMStateError VMState = "error"
|
|
)
|
|
|
|
type GuestSessionStatus string
|
|
|
|
const (
|
|
GuestSessionStatusStarting GuestSessionStatus = "starting"
|
|
GuestSessionStatusRunning GuestSessionStatus = "running"
|
|
GuestSessionStatusExited GuestSessionStatus = "exited"
|
|
GuestSessionStatusFailed GuestSessionStatus = "failed"
|
|
GuestSessionStatusStopping GuestSessionStatus = "stopping"
|
|
)
|
|
|
|
type GuestSessionStdinMode string
|
|
|
|
const (
|
|
GuestSessionStdinClosed GuestSessionStdinMode = "closed"
|
|
GuestSessionStdinPipe GuestSessionStdinMode = "pipe"
|
|
)
|
|
|
|
type DaemonConfig struct {
|
|
LogLevel string
|
|
WebListenAddr string
|
|
FirecrackerBin string
|
|
SSHKeyPath string
|
|
AutoStopStaleAfter time.Duration
|
|
StatsPollInterval time.Duration
|
|
MetricsPollInterval time.Duration
|
|
BridgeName string
|
|
BridgeIP string
|
|
CIDR string
|
|
TapPoolSize int
|
|
DefaultDNS string
|
|
DefaultImageName string
|
|
}
|
|
|
|
type Image struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Managed bool `json:"managed"`
|
|
ArtifactDir string `json:"artifact_dir,omitempty"`
|
|
RootfsPath string `json:"rootfs_path"`
|
|
WorkSeedPath string `json:"work_seed_path,omitempty"`
|
|
KernelPath string `json:"kernel_path"`
|
|
InitrdPath string `json:"initrd_path,omitempty"`
|
|
ModulesDir string `json:"modules_dir,omitempty"`
|
|
BuildSize string `json:"build_size,omitempty"`
|
|
SeededSSHPublicKeyFingerprint string `json:"seeded_ssh_public_key_fingerprint,omitempty"`
|
|
Docker bool `json:"docker"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type VMSpec struct {
|
|
VCPUCount int `json:"vcpu_count"`
|
|
MemoryMiB int `json:"memory_mib"`
|
|
SystemOverlaySizeByte int64 `json:"system_overlay_size_bytes"`
|
|
WorkDiskSizeBytes int64 `json:"work_disk_size_bytes"`
|
|
NATEnabled bool `json:"nat_enabled"`
|
|
}
|
|
|
|
type VMRuntime struct {
|
|
State VMState `json:"state"`
|
|
PID int `json:"pid,omitempty"`
|
|
GuestIP string `json:"guest_ip"`
|
|
TapDevice string `json:"tap_device,omitempty"`
|
|
APISockPath string `json:"api_sock_path,omitempty"`
|
|
VSockPath string `json:"vsock_path,omitempty"`
|
|
VSockCID uint32 `json:"vsock_cid,omitempty"`
|
|
LogPath string `json:"log_path,omitempty"`
|
|
MetricsPath string `json:"metrics_path,omitempty"`
|
|
DNSName string `json:"dns_name,omitempty"`
|
|
VMDir string `json:"vm_dir"`
|
|
SystemOverlay string `json:"system_overlay_path"`
|
|
WorkDiskPath string `json:"work_disk_path"`
|
|
BaseLoop string `json:"base_loop,omitempty"`
|
|
COWLoop string `json:"cow_loop,omitempty"`
|
|
DMName string `json:"dm_name,omitempty"`
|
|
DMDev string `json:"dm_dev,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
type VMStats struct {
|
|
CollectedAt time.Time `json:"collected_at,omitempty"`
|
|
CPUPercent float64 `json:"cpu_percent,omitempty"`
|
|
RSSBytes int64 `json:"rss_bytes,omitempty"`
|
|
VSZBytes int64 `json:"vsz_bytes,omitempty"`
|
|
SystemOverlayBytes int64 `json:"system_overlay_bytes,omitempty"`
|
|
WorkDiskBytes int64 `json:"work_disk_bytes,omitempty"`
|
|
MetricsRaw map[string]any `json:"metrics_raw,omitempty"`
|
|
}
|
|
|
|
type VMRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ImageID string `json:"image_id"`
|
|
State VMState `json:"state"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
LastTouchedAt time.Time `json:"last_touched_at"`
|
|
Spec VMSpec `json:"spec"`
|
|
Runtime VMRuntime `json:"runtime"`
|
|
Stats VMStats `json:"stats"`
|
|
}
|
|
|
|
type VMCreateRequest struct {
|
|
Name string
|
|
ImageName string
|
|
VCPUCount int
|
|
MemoryMiB int
|
|
SystemOverlaySizeByte int64
|
|
WorkDiskSizeBytes int64
|
|
NATEnabled bool
|
|
NoStart bool
|
|
}
|
|
|
|
type VMSetRequest struct {
|
|
IDOrName string
|
|
VCPUCount *int
|
|
MemoryMiB *int
|
|
WorkDiskSizeBytes *int64
|
|
NATEnabled *bool
|
|
}
|
|
|
|
type ImageBuildRequest struct {
|
|
Name string
|
|
FromImage string
|
|
Size string
|
|
KernelPath string
|
|
InitrdPath string
|
|
ModulesDir string
|
|
Docker bool
|
|
}
|
|
|
|
type GuestSession struct {
|
|
ID string `json:"id"`
|
|
VMID string `json:"vm_id"`
|
|
Name string `json:"name"`
|
|
Backend string `json:"backend"`
|
|
AttachBackend string `json:"attach_backend,omitempty"`
|
|
AttachMode string `json:"attach_mode,omitempty"`
|
|
Command string `json:"command"`
|
|
Args []string `json:"args,omitempty"`
|
|
CWD string `json:"cwd,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
StdinMode GuestSessionStdinMode `json:"stdin_mode,omitempty"`
|
|
Status GuestSessionStatus `json:"status"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
GuestPID int `json:"guest_pid,omitempty"`
|
|
GuestStateDir string `json:"guest_state_dir,omitempty"`
|
|
StdoutLogPath string `json:"stdout_log_path,omitempty"`
|
|
StderrLogPath string `json:"stderr_log_path,omitempty"`
|
|
Tags map[string]string `json:"tags,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
Attachable bool `json:"attachable"`
|
|
Reattachable bool `json:"reattachable"`
|
|
LaunchStage string `json:"launch_stage,omitempty"`
|
|
LaunchMessage string `json:"launch_message,omitempty"`
|
|
LaunchRawLog string `json:"launch_raw_log,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
StartedAt time.Time `json:"started_at,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
EndedAt time.Time `json:"ended_at,omitempty"`
|
|
}
|
|
|
|
type WorkspacePrepareMode string
|
|
|
|
const (
|
|
WorkspacePrepareModeShallowOverlay WorkspacePrepareMode = "shallow_overlay"
|
|
WorkspacePrepareModeFullCopy WorkspacePrepareMode = "full_copy"
|
|
WorkspacePrepareModeMetadataOnly WorkspacePrepareMode = "metadata_only"
|
|
)
|
|
|
|
type WorkspacePrepareResult struct {
|
|
VMID string `json:"vm_id"`
|
|
SourcePath string `json:"source_path"`
|
|
RepoRoot string `json:"repo_root"`
|
|
RepoName string `json:"repo_name"`
|
|
GuestPath string `json:"guest_path"`
|
|
Mode WorkspacePrepareMode `json:"mode"`
|
|
ReadOnly bool `json:"readonly"`
|
|
HeadCommit string `json:"head_commit,omitempty"`
|
|
CurrentBranch string `json:"current_branch,omitempty"`
|
|
BranchName string `json:"branch_name,omitempty"`
|
|
BaseCommit string `json:"base_commit,omitempty"`
|
|
PreparedAt time.Time `json:"prepared_at"`
|
|
}
|
|
|
|
func Now() time.Time {
|
|
return time.Now().UTC().Truncate(time.Second)
|
|
}
|
|
|
|
func NewID() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
func ParseSize(raw string) (int64, error) {
|
|
if raw == "" {
|
|
return 0, errors.New("size is required")
|
|
}
|
|
raw = strings.TrimSpace(strings.ToUpper(raw))
|
|
if raw == "" {
|
|
return 0, errors.New("size is required")
|
|
}
|
|
unit := raw[len(raw)-1]
|
|
multiplier := int64(1024 * 1024)
|
|
number := raw
|
|
switch unit {
|
|
case 'K':
|
|
multiplier = 1024
|
|
number = raw[:len(raw)-1]
|
|
case 'M':
|
|
multiplier = 1024 * 1024
|
|
number = raw[:len(raw)-1]
|
|
case 'G':
|
|
multiplier = 1024 * 1024 * 1024
|
|
number = raw[:len(raw)-1]
|
|
default:
|
|
if unit < '0' || unit > '9' {
|
|
return 0, fmt.Errorf("unsupported size suffix: %q", string(unit))
|
|
}
|
|
}
|
|
value, err := strconv.ParseInt(number, 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse size %q: %w", raw, err)
|
|
}
|
|
result := value * multiplier
|
|
if result <= 0 {
|
|
return 0, fmt.Errorf("size must be positive: %q", raw)
|
|
}
|
|
if result > MaxDiskBytes {
|
|
return 0, fmt.Errorf("size exceeds max of %d bytes", MaxDiskBytes)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func FormatSizeBytes(bytes int64) string {
|
|
switch {
|
|
case bytes%(1024*1024*1024) == 0:
|
|
return fmt.Sprintf("%dG", bytes/(1024*1024*1024))
|
|
case bytes%(1024*1024) == 0:
|
|
return fmt.Sprintf("%dM", bytes/(1024*1024))
|
|
case bytes%1024 == 0:
|
|
return fmt.Sprintf("%dK", bytes/1024)
|
|
default:
|
|
return strconv.FormatInt(bytes, 10)
|
|
}
|
|
}
|