* model.ParseSize / FormatSizeBytes: pinned with table tests in
internal/model/types_test.go (TestParseSize 22 cases,
TestFormatSizeBytes 11 cases, TestParseSizeFormatRoundTrip 7
boundaries). Fixed the long-suffix regression: "4GiB", "512MiB",
"4KiB" now parse correctly (parser strips trailing IB before
inspecting the unit byte). Pinned current behaviour for
no-suffix input ("1024" treated as MiB) and FormatSizeBytes(0).
commands_image.go --size flag-help updated to show 4GiB now
that the parser accepts it.
* vm ports --json: matches the JSON-vs-table inconsistency between
vm stats (always JSON) and vm ports (always table). --json on
vm ports flips to the same printJSON path as vm stats. Default
table output unchanged. Other vm subcommands (show, stats,
logs, health, ping) didn't fit the identical pattern; left
alone.
* docs/oci-import.md architecture section moved to a new
docs/oci-import-internals.md (precedent: internal/daemon/
ARCHITECTURE.md). User-facing oci-import.md keeps a one-line
pointer for advanced reading.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
10 KiB
Go
289 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
|
|
MaxDiskBytes int64 = 128 * 1024 * 1024 * 1024
|
|
DefaultJailerBinary = "/usr/bin/jailer"
|
|
)
|
|
|
|
type VMState string
|
|
|
|
const (
|
|
VMStateCreated VMState = "created"
|
|
VMStateRunning VMState = "running"
|
|
VMStateStopped VMState = "stopped"
|
|
VMStateError VMState = "error"
|
|
)
|
|
|
|
type DaemonConfig struct {
|
|
LogLevel string
|
|
FirecrackerBin string
|
|
JailerBin string
|
|
JailerEnabled bool
|
|
JailerChrootBase string
|
|
SSHKeyPath string
|
|
HostHomeDir string
|
|
AutoStopStaleAfter time.Duration
|
|
StatsPollInterval 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 configured owner home for "~/..." and must stay within
|
|
// that home; 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"`
|
|
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. The authoritative live handle set still lives on VMHandles,
|
|
// but teardown-critical storage/network identifiers are mirrored here
|
|
// as recovery fallbacks so restart-time cleanup still works when
|
|
// handles.json is missing or corrupt.
|
|
//
|
|
// 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"`
|
|
// Teardown fallback fields mirror the handle cache onto the VM row.
|
|
// They are recovery-only: while the daemon is alive, VMHandles stays
|
|
// authoritative. On restart, cleanup can fall back to these values if
|
|
// handles.json is missing or corrupt.
|
|
TapDevice string `json:"tap_device,omitempty"`
|
|
BaseLoop string `json:"base_loop,omitempty"`
|
|
COWLoop string `json:"cow_loop,omitempty"`
|
|
DMName string `json:"dm_name,omitempty"`
|
|
DMDev string `json:"dm_dev,omitempty"`
|
|
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"`
|
|
Workspace VMWorkspace `json:"workspace"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// VMWorkspace records the last successful workspace.prepare result on
|
|
// a VM so callers can skip re-stating the source path on every exec
|
|
// and so banger can detect drift between the guest copy and the host
|
|
// repo. Stored as workspace_json in the vms table; zero value means
|
|
// no workspace has been prepared on this VM yet.
|
|
type VMWorkspace struct {
|
|
GuestPath string `json:"guest_path,omitempty"`
|
|
SourcePath string `json:"source_path,omitempty"`
|
|
HeadCommit string `json:"head_commit,omitempty"`
|
|
PreparedAt time.Time `json:"prepared_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"`
|
|
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
|
|
}
|
|
|
|
// NewOpID returns a short identifier for tracing a single RPC
|
|
// operation across the daemon, the root helper, and the user-visible
|
|
// CLI error string. Format: "op-" + 12 hex chars (48 bits of entropy
|
|
// — collisions inside one daemon session are vanishingly unlikely
|
|
// and don't matter beyond it). Short enough to copy-paste from a
|
|
// CLI error into a journalctl --grep, long enough to actually
|
|
// disambiguate.
|
|
func NewOpID() (string, error) {
|
|
buf := make([]byte, 6)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return "op-" + 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")
|
|
}
|
|
// Strip an optional "IB" suffix so that "GiB", "MiB", "KiB" work the
|
|
// same as "G", "M", "K" (case-insensitive after ToUpper).
|
|
number := strings.TrimSuffix(raw, "IB")
|
|
unit := number[len(number)-1]
|
|
multiplier := int64(1024 * 1024)
|
|
switch unit {
|
|
case 'K':
|
|
multiplier = 1024
|
|
number = number[:len(number)-1]
|
|
case 'M':
|
|
multiplier = 1024 * 1024
|
|
number = number[:len(number)-1]
|
|
case 'G':
|
|
multiplier = 1024 * 1024 * 1024
|
|
number = number[:len(number)-1]
|
|
default:
|
|
if unit < '0' || unit > '9' {
|
|
return 0, fmt.Errorf("unsupported size suffix: %q", string(unit))
|
|
}
|
|
number = raw // no suffix stripped — keep original digits-only string
|
|
}
|
|
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)
|
|
}
|
|
}
|