The web UI shipped as "experimental" and was never finished — no nav
off the dashboard, no live updates, no settled design, never a
supported surface. It was opt-in by default already; leaving the code
in the tree for v0.1.0 only invited "does this work?" questions and
kept HostSummary/BangerSummary/SudoStatus types on the public RPC
surface that nothing else uses.
Removed:
internal/webui/ (all Go + templates + assets)
internal/daemon/web.go (server start / Layout / Config / ListVMs / ListImages)
internal/daemon/dashboard.go (DashboardSummary aggregator)
Simplified:
internal/api/types.go drop WebURL on PingResult, drop
HostSummary / SudoStatus / BangerSummary /
DashboardSummary / DashboardSummaryResult
internal/model/types.go drop DaemonConfig.WebListenAddr
internal/config/config.go drop web_listen_addr from fileConfig + Load
internal/daemon/daemon.go drop webListener / webServer / webURL fields +
startWebServer() call + ping WebURL population
internal/cli/banger.go `daemon status` output no longer branches on web
internal/daemon/{doc.go,ARCHITECTURE.md} drop web UI sections
README.md drop web_listen_addr config bullet + security paragraph
Tests updated to reflect the new shape. Coverage 57.3 -> 58.9% (the
webui package was largely untested; its removal lifts the ratio
without moving the numerator). `banger daemon status` output and
--help are web-free. Lint + full suite green.
296 lines
10 KiB
Go
296 lines
10 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
|
|
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
|
|
FileSync []FileSyncEntry
|
|
VMDefaults VMDefaultsOverride
|
|
}
|
|
|
|
// FileSyncEntry is a user-declared host→guest file or directory copy
|
|
// applied to each VM's work disk at vm create time. Host is expanded
|
|
// against the host user's $HOME for "~/..."; Guest is expanded
|
|
// against /root (banger VMs are single-user root). If the host path
|
|
// is a directory, it's copied recursively; if it's a file, it's
|
|
// copied as a file. Missing host paths are a soft skip (warned, not
|
|
// fatal). Mode defaults to 0600 for files and 0755 for directories.
|
|
type FileSyncEntry struct {
|
|
Host string
|
|
Guest string
|
|
Mode 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"`
|
|
}
|
|
|
|
// VMRuntime holds the durable runtime state that the daemon needs
|
|
// to reach a VM: identity, declared state, and deterministic derived
|
|
// paths. Transient kernel/process handles (PID, tap, loop devices,
|
|
// dm-snapshot names) live on VMHandles, NOT here — the daemon keeps
|
|
// them in an in-memory cache backed by a per-VM handles.json scratch
|
|
// file, so a daemon restart rebuilds them from OS state rather than
|
|
// trusting whatever was last written into a SQLite column.
|
|
//
|
|
// Everything in VMRuntime is safe to persist: the paths are
|
|
// deterministic from (VM ID, layout) and survive restart unchanged;
|
|
// GuestIP and DNSName are assigned at create time and never move;
|
|
// LastError carries the last failure message for debugging. State
|
|
// mirrors VMRecord.State.
|
|
type VMRuntime struct {
|
|
State VMState `json:"state"`
|
|
GuestIP string `json:"guest_ip"`
|
|
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"`
|
|
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 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)
|
|
}
|
|
}
|