Volt CLI: source-available under AGPSL v5.0

Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
This commit is contained in:
Karl Clinger
2026-03-21 00:30:23 -05:00
commit 0ebe75b2ca
155 changed files with 63317 additions and 0 deletions

999
pkg/backend/proot/proot.go Normal file
View File

@@ -0,0 +1,999 @@
/*
Proot Backend — Container runtime for Android and non-systemd Linux platforms.
Uses proot (ptrace-based root emulation) for filesystem isolation, modeled
after the ACE (Android Container Engine) runtime. No root required, no
cgroups, no namespaces — runs containers in user-space via syscall
interception.
Key design decisions from ACE:
- proot -r <rootfs> -0 -w / -k 5.15.0 -b /dev -b /proc -b /sys
- Entrypoint auto-detection: /init → nginx → docker-entrypoint.sh → /bin/sh
- Container state persisted as JSON files
- Logs captured via redirected stdout/stderr
- Port remapping via sed-based config modification (no iptables)
*/
package proot
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/armoredgate/volt/pkg/backend"
"gopkg.in/yaml.v3"
)
// containerState represents the runtime state persisted to state.json.
type containerState struct {
Name string `json:"name"`
Status string `json:"status"` // created, running, stopped
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at,omitempty"`
StoppedAt time.Time `json:"stopped_at,omitempty"`
}
// containerConfig represents the container configuration persisted to config.yaml.
type containerConfig struct {
Name string `yaml:"name"`
Image string `yaml:"image,omitempty"`
RootFS string `yaml:"rootfs"`
Memory string `yaml:"memory,omitempty"`
CPU int `yaml:"cpu,omitempty"`
Env []string `yaml:"env,omitempty"`
Ports []backend.PortMapping `yaml:"ports,omitempty"`
Volumes []backend.VolumeMount `yaml:"volumes,omitempty"`
Network string `yaml:"network,omitempty"`
}
func init() {
backend.Register("proot", func() backend.ContainerBackend { return New() })
}
// Backend implements backend.ContainerBackend using proot.
type Backend struct {
dataDir string
prootPath string
}
// New creates a new proot backend instance.
func New() *Backend {
return &Backend{}
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Identity & Availability
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Name() string { return "proot" }
// Available returns true if a usable proot binary can be found.
func (b *Backend) Available() bool {
return b.findProot() != ""
}
// findProot locates the proot binary, checking PATH first, then common
// Android locations.
func (b *Backend) findProot() string {
// Already resolved
if b.prootPath != "" {
if _, err := os.Stat(b.prootPath); err == nil {
return b.prootPath
}
}
// Standard PATH lookup
if p, err := exec.LookPath("proot"); err == nil {
return p
}
// Android-specific locations
androidPaths := []string{
"/data/local/tmp/proot",
"/data/data/com.termux/files/usr/bin/proot",
}
// Also check app native lib dirs (ACE pattern)
if home := os.Getenv("HOME"); home != "" {
androidPaths = append(androidPaths, filepath.Join(home, "proot"))
}
for _, p := range androidPaths {
if info, err := os.Stat(p); err == nil && !info.IsDir() {
return p
}
}
return ""
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Init
// ──────────────────────────────────────────────────────────────────────────────
// Init creates the backend directory structure and resolves the proot binary.
func (b *Backend) Init(dataDir string) error {
b.dataDir = dataDir
b.prootPath = b.findProot()
dirs := []string{
filepath.Join(dataDir, "containers"),
filepath.Join(dataDir, "images"),
filepath.Join(dataDir, "tmp"),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("proot init: failed to create %s: %w", d, err)
}
}
// Set permissions on tmp directory (ACE pattern — proot needs a writable tmp)
if err := os.Chmod(filepath.Join(dataDir, "tmp"), 0777); err != nil {
return fmt.Errorf("proot init: failed to chmod tmp: %w", err)
}
return nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Create
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Create(opts backend.CreateOptions) error {
cDir := b.containerDir(opts.Name)
// Check for duplicates
if _, err := os.Stat(cDir); err == nil {
return fmt.Errorf("container %q already exists", opts.Name)
}
// Create directory structure
subdirs := []string{
filepath.Join(cDir, "rootfs"),
filepath.Join(cDir, "logs"),
}
for _, d := range subdirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("create: mkdir %s: %w", d, err)
}
}
rootfsDir := filepath.Join(cDir, "rootfs")
// Populate rootfs
if opts.RootFS != "" {
// Use provided rootfs directory — symlink or copy
srcInfo, err := os.Stat(opts.RootFS)
if err != nil {
return fmt.Errorf("create: rootfs path %q not found: %w", opts.RootFS, err)
}
if !srcInfo.IsDir() {
return fmt.Errorf("create: rootfs path %q is not a directory", opts.RootFS)
}
// Copy the rootfs contents
if err := copyDir(opts.RootFS, rootfsDir); err != nil {
return fmt.Errorf("create: copy rootfs: %w", err)
}
} else if opts.Image != "" {
// Check if image already exists as an extracted rootfs in images dir
imagePath := b.resolveImage(opts.Image)
if imagePath != "" {
if err := copyDir(imagePath, rootfsDir); err != nil {
return fmt.Errorf("create: copy image rootfs: %w", err)
}
} else {
// Try debootstrap for base Debian/Ubuntu images
if isDebootstrapImage(opts.Image) {
if err := b.debootstrap(opts.Image, rootfsDir); err != nil {
return fmt.Errorf("create: debootstrap failed: %w", err)
}
} else {
// Create minimal rootfs structure for manual population
for _, d := range []string{"bin", "etc", "home", "root", "tmp", "usr/bin", "usr/sbin", "var/log"} {
os.MkdirAll(filepath.Join(rootfsDir, d), 0755)
}
}
}
}
// Write config.yaml
cfg := containerConfig{
Name: opts.Name,
Image: opts.Image,
RootFS: rootfsDir,
Memory: opts.Memory,
CPU: opts.CPU,
Env: opts.Env,
Ports: opts.Ports,
Volumes: opts.Volumes,
Network: opts.Network,
}
if err := b.writeConfig(opts.Name, &cfg); err != nil {
// Clean up on failure
os.RemoveAll(cDir)
return fmt.Errorf("create: write config: %w", err)
}
// Write initial state.json
state := containerState{
Name: opts.Name,
Status: "created",
PID: 0,
CreatedAt: time.Now(),
}
if err := b.writeState(opts.Name, &state); err != nil {
os.RemoveAll(cDir)
return fmt.Errorf("create: write state: %w", err)
}
// Auto-start if requested
if opts.Start {
return b.Start(opts.Name)
}
return nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Start
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Start(name string) error {
state, err := b.readState(name)
if err != nil {
return fmt.Errorf("start: %w", err)
}
if state.Status == "running" {
// Check if the PID is actually alive
if state.PID > 0 && processAlive(state.PID) {
return fmt.Errorf("container %q is already running (pid %d)", name, state.PID)
}
// Stale state — process died, update and continue
state.Status = "stopped"
}
if state.Status != "created" && state.Status != "stopped" {
return fmt.Errorf("container %q is in state %q, cannot start", name, state.Status)
}
cfg, err := b.readConfig(name)
if err != nil {
return fmt.Errorf("start: %w", err)
}
if b.prootPath == "" {
return fmt.Errorf("start: proot binary not found — install proot or set PATH")
}
rootfsDir := filepath.Join(b.containerDir(name), "rootfs")
// Detect entrypoint (ACE priority order)
entrypoint, entrypointArgs := b.detectEntrypoint(rootfsDir, cfg)
// Build proot command arguments
args := []string{
"-r", rootfsDir,
"-0", // Fake root (uid 0 emulation)
"-w", "/", // Working directory inside container
"-k", "5.15.0", // Fake kernel version for compatibility
"-b", "/dev", // Bind /dev
"-b", "/proc", // Bind /proc
"-b", "/sys", // Bind /sys
"-b", "/dev/urandom:/dev/random", // Fix random device
}
// Add volume mounts as proot bind mounts
for _, vol := range cfg.Volumes {
bindArg := vol.HostPath + ":" + vol.ContainerPath
args = append(args, "-b", bindArg)
}
// Add entrypoint
args = append(args, entrypoint)
args = append(args, entrypointArgs...)
cmd := exec.Command(b.prootPath, args...)
// Set container environment variables (ACE pattern)
env := []string{
"HOME=/root",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm",
"CONTAINER_NAME=" + name,
"PROOT_NO_SECCOMP=1",
"PROOT_TMP_DIR=" + filepath.Join(b.dataDir, "tmp"),
"TMPDIR=" + filepath.Join(b.dataDir, "tmp"),
}
// Add user-specified environment variables
env = append(env, cfg.Env...)
// Add port mapping info as environment variables
for _, p := range cfg.Ports {
env = append(env,
fmt.Sprintf("PORT_%d=%d", p.ContainerPort, p.HostPort),
)
}
cmd.Env = env
// Create a new session so the child doesn't get signals from our terminal
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
// Redirect stdout/stderr to log file
logDir := filepath.Join(b.containerDir(name), "logs")
os.MkdirAll(logDir, 0755)
logPath := filepath.Join(logDir, "current.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("start: open log file: %w", err)
}
// Write startup header to log
fmt.Fprintf(logFile, "[volt] Container %s starting at %s\n", name, time.Now().Format(time.RFC3339))
fmt.Fprintf(logFile, "[volt] proot=%s\n", b.prootPath)
fmt.Fprintf(logFile, "[volt] rootfs=%s\n", rootfsDir)
fmt.Fprintf(logFile, "[volt] entrypoint=%s %s\n", entrypoint, strings.Join(entrypointArgs, " "))
cmd.Stdout = logFile
cmd.Stderr = logFile
// Start the process
if err := cmd.Start(); err != nil {
logFile.Close()
return fmt.Errorf("start: exec proot: %w", err)
}
// Close the log file handle in the parent — the child has its own fd
logFile.Close()
// Update state
state.Status = "running"
state.PID = cmd.Process.Pid
state.StartedAt = time.Now()
if err := b.writeState(name, state); err != nil {
// Kill the process if we can't persist state
cmd.Process.Signal(syscall.SIGKILL)
return fmt.Errorf("start: write state: %w", err)
}
// Reap the child in a goroutine to avoid zombies
go func() {
cmd.Wait()
// Process exited — update state to stopped
if s, err := b.readState(name); err == nil && s.Status == "running" {
s.Status = "stopped"
s.PID = 0
s.StoppedAt = time.Now()
b.writeState(name, s)
}
}()
return nil
}
// detectEntrypoint determines what to run inside the container.
// Follows ACE priority: /init → nginx → docker-entrypoint.sh → /bin/sh
func (b *Backend) detectEntrypoint(rootfsDir string, cfg *containerConfig) (string, []string) {
// Check for common entrypoints in the rootfs
candidates := []struct {
path string
args []string
}{
{"/init", nil},
{"/usr/sbin/nginx", []string{"-g", "daemon off; master_process off;"}},
{"/docker-entrypoint.sh", nil},
{"/usr/local/bin/python3", nil},
{"/usr/bin/python3", nil},
}
for _, c := range candidates {
fullPath := filepath.Join(rootfsDir, c.path)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
// For nginx with port mappings, rewrite the listen port via shell wrapper
if c.path == "/usr/sbin/nginx" && len(cfg.Ports) > 0 {
port := cfg.Ports[0].HostPort
shellCmd := fmt.Sprintf(
"sed -i 's/listen[[:space:]]*80;/listen %d;/g' /etc/nginx/conf.d/default.conf 2>/dev/null; "+
"sed -i 's/listen[[:space:]]*80;/listen %d;/g' /etc/nginx/nginx.conf 2>/dev/null; "+
"exec /usr/sbin/nginx -g 'daemon off; master_process off;'",
port, port,
)
return "/bin/sh", []string{"-c", shellCmd}
}
return c.path, c.args
}
}
// Fallback: /bin/sh
return "/bin/sh", nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Stop
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Stop(name string) error {
state, err := b.readState(name)
if err != nil {
return fmt.Errorf("stop: %w", err)
}
if state.Status != "running" || state.PID <= 0 {
// Already stopped — make sure state reflects it
if state.Status == "running" {
state.Status = "stopped"
state.PID = 0
b.writeState(name, state)
}
return nil
}
proc, err := os.FindProcess(state.PID)
if err != nil {
// Process doesn't exist — clean up state
state.Status = "stopped"
state.PID = 0
state.StoppedAt = time.Now()
return b.writeState(name, state)
}
// Send SIGTERM for graceful shutdown (ACE pattern)
proc.Signal(syscall.SIGTERM)
// Wait briefly for graceful exit
done := make(chan struct{})
go func() {
// Wait up to 5 seconds for the process to exit
for i := 0; i < 50; i++ {
if !processAlive(state.PID) {
close(done)
return
}
time.Sleep(100 * time.Millisecond)
}
close(done)
}()
<-done
// If still running, force kill
if processAlive(state.PID) {
proc.Signal(syscall.SIGKILL)
// Give it a moment to die
time.Sleep(200 * time.Millisecond)
}
// Update state
state.Status = "stopped"
state.PID = 0
state.StoppedAt = time.Now()
return b.writeState(name, state)
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Delete
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Delete(name string, force bool) error {
state, err := b.readState(name)
if err != nil {
// If state can't be read but directory exists, allow force delete
cDir := b.containerDir(name)
if _, statErr := os.Stat(cDir); statErr != nil {
return fmt.Errorf("container %q not found", name)
}
if !force {
return fmt.Errorf("delete: cannot read state for %q (use --force): %w", name, err)
}
// Force remove the whole directory
return os.RemoveAll(cDir)
}
if state.Status == "running" && state.PID > 0 && processAlive(state.PID) {
if !force {
return fmt.Errorf("container %q is running — stop it first or use --force", name)
}
// Force stop
if err := b.Stop(name); err != nil {
// If stop fails, try direct kill
if proc, err := os.FindProcess(state.PID); err == nil {
proc.Signal(syscall.SIGKILL)
time.Sleep(200 * time.Millisecond)
}
}
}
// Remove entire container directory
cDir := b.containerDir(name)
if err := os.RemoveAll(cDir); err != nil {
return fmt.Errorf("delete: remove %s: %w", cDir, err)
}
return nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Exec
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Exec(name string, opts backend.ExecOptions) error {
state, err := b.readState(name)
if err != nil {
return fmt.Errorf("exec: %w", err)
}
if state.Status != "running" || state.PID <= 0 || !processAlive(state.PID) {
return fmt.Errorf("container %q is not running", name)
}
if len(opts.Command) == 0 {
opts.Command = []string{"/bin/sh"}
}
cfg, err := b.readConfig(name)
if err != nil {
return fmt.Errorf("exec: %w", err)
}
rootfsDir := filepath.Join(b.containerDir(name), "rootfs")
// Build proot command for exec
args := []string{
"-r", rootfsDir,
"-0",
"-w", "/",
"-k", "5.15.0",
"-b", "/dev",
"-b", "/proc",
"-b", "/sys",
"-b", "/dev/urandom:/dev/random",
}
// Add volume mounts
for _, vol := range cfg.Volumes {
args = append(args, "-b", vol.HostPath+":"+vol.ContainerPath)
}
// Add the command
args = append(args, opts.Command...)
cmd := exec.Command(b.prootPath, args...)
// Set container environment
env := []string{
"HOME=/root",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm",
"CONTAINER_NAME=" + name,
"PROOT_NO_SECCOMP=1",
"PROOT_TMP_DIR=" + filepath.Join(b.dataDir, "tmp"),
}
env = append(env, cfg.Env...)
env = append(env, opts.Env...)
cmd.Env = env
// Attach stdin/stdout/stderr for interactive use
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Logs
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) Logs(name string, opts backend.LogOptions) (string, error) {
logPath := filepath.Join(b.containerDir(name), "logs", "current.log")
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return "[No logs available]", nil
}
return "", fmt.Errorf("logs: read %s: %w", logPath, err)
}
content := string(data)
if opts.Tail > 0 {
lines := strings.Split(content, "\n")
if len(lines) > opts.Tail {
lines = lines[len(lines)-opts.Tail:]
}
return strings.Join(lines, "\n"), nil
}
return content, nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: CopyToContainer / CopyFromContainer
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) CopyToContainer(name string, src string, dst string) error {
// Verify container exists
cDir := b.containerDir(name)
if _, err := os.Stat(cDir); err != nil {
return fmt.Errorf("container %q not found", name)
}
// Destination is relative to rootfs
dstPath := filepath.Join(cDir, "rootfs", dst)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return fmt.Errorf("copy-to: mkdir: %w", err)
}
return copyFile(src, dstPath)
}
func (b *Backend) CopyFromContainer(name string, src string, dst string) error {
// Verify container exists
cDir := b.containerDir(name)
if _, err := os.Stat(cDir); err != nil {
return fmt.Errorf("container %q not found", name)
}
// Source is relative to rootfs
srcPath := filepath.Join(cDir, "rootfs", src)
// Ensure parent directory of destination exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return fmt.Errorf("copy-from: mkdir: %w", err)
}
return copyFile(srcPath, dst)
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: List & Inspect
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) List() ([]backend.ContainerInfo, error) {
containersDir := filepath.Join(b.dataDir, "containers")
entries, err := os.ReadDir(containersDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("list: read containers dir: %w", err)
}
var result []backend.ContainerInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
info, err := b.Inspect(name)
if err != nil {
// Skip containers with broken state
continue
}
result = append(result, *info)
}
return result, nil
}
func (b *Backend) Inspect(name string) (*backend.ContainerInfo, error) {
state, err := b.readState(name)
if err != nil {
return nil, fmt.Errorf("inspect: %w", err)
}
cfg, err := b.readConfig(name)
if err != nil {
return nil, fmt.Errorf("inspect: %w", err)
}
// Reconcile state: if status says running, verify the PID is alive
if state.Status == "running" && state.PID > 0 {
if !processAlive(state.PID) {
state.Status = "stopped"
state.PID = 0
state.StoppedAt = time.Now()
b.writeState(name, state)
}
}
// Detect OS from rootfs os-release
osName := detectOS(filepath.Join(b.containerDir(name), "rootfs"))
info := &backend.ContainerInfo{
Name: name,
Image: cfg.Image,
Status: state.Status,
PID: state.PID,
RootFS: cfg.RootFS,
Memory: cfg.Memory,
CPU: cfg.CPU,
CreatedAt: state.CreatedAt,
StartedAt: state.StartedAt,
IPAddress: "-", // proot shares host network
OS: osName,
}
return info, nil
}
// ──────────────────────────────────────────────────────────────────────────────
// Interface: Platform Capabilities
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) SupportsVMs() bool { return false }
func (b *Backend) SupportsServices() bool { return false }
func (b *Backend) SupportsNetworking() bool { return true } // basic port forwarding
func (b *Backend) SupportsTuning() bool { return false }
// ──────────────────────────────────────────────────────────────────────────────
// Internal: State & Config persistence
// ──────────────────────────────────────────────────────────────────────────────
func (b *Backend) containerDir(name string) string {
return filepath.Join(b.dataDir, "containers", name)
}
func (b *Backend) readState(name string) (*containerState, error) {
path := filepath.Join(b.containerDir(name), "state.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read state for %q: %w", name, err)
}
var state containerState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("parse state for %q: %w", name, err)
}
return &state, nil
}
func (b *Backend) writeState(name string, state *containerState) error {
path := filepath.Join(b.containerDir(name), "state.json")
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshal state for %q: %w", name, err)
}
return os.WriteFile(path, data, 0644)
}
func (b *Backend) readConfig(name string) (*containerConfig, error) {
path := filepath.Join(b.containerDir(name), "config.yaml")
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config for %q: %w", name, err)
}
var cfg containerConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config for %q: %w", name, err)
}
return &cfg, nil
}
func (b *Backend) writeConfig(name string, cfg *containerConfig) error {
path := filepath.Join(b.containerDir(name), "config.yaml")
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config for %q: %w", name, err)
}
return os.WriteFile(path, data, 0644)
}
// ──────────────────────────────────────────────────────────────────────────────
// Internal: Image resolution
// ──────────────────────────────────────────────────────────────────────────────
// resolveImage checks if an image rootfs exists in the images directory.
func (b *Backend) resolveImage(image string) string {
imagesDir := filepath.Join(b.dataDir, "images")
// Try exact name
candidate := filepath.Join(imagesDir, image)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
// Try normalized name (replace : with _)
normalized := strings.ReplaceAll(image, ":", "_")
normalized = strings.ReplaceAll(normalized, "/", "_")
candidate = filepath.Join(imagesDir, normalized)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
return ""
}
// isDebootstrapImage checks if the image name is a Debian/Ubuntu variant
// that can be bootstrapped with debootstrap.
func isDebootstrapImage(image string) bool {
base := strings.Split(image, ":")[0]
base = strings.Split(base, "/")[len(strings.Split(base, "/"))-1]
debootstrapDistros := []string{
"debian", "ubuntu", "bookworm", "bullseye", "buster",
"jammy", "focal", "noble", "mantic",
}
for _, d := range debootstrapDistros {
if strings.EqualFold(base, d) {
return true
}
}
return false
}
// debootstrap creates a Debian/Ubuntu rootfs using debootstrap.
func (b *Backend) debootstrap(image string, rootfsDir string) error {
// Determine the suite (release codename)
parts := strings.SplitN(image, ":", 2)
base := parts[0]
suite := ""
if len(parts) == 2 {
suite = parts[1]
}
// Map image names to suites
if suite == "" {
switch strings.ToLower(base) {
case "debian":
suite = "bookworm"
case "ubuntu":
suite = "noble"
default:
suite = strings.ToLower(base)
}
}
// Check if debootstrap is available
debootstrapPath, err := exec.LookPath("debootstrap")
if err != nil {
return fmt.Errorf("debootstrap not found in PATH — install debootstrap to create base images")
}
// Determine mirror based on distro
mirror := "http://deb.debian.org/debian"
if strings.EqualFold(base, "ubuntu") || isUbuntuSuite(suite) {
mirror = "http://archive.ubuntu.com/ubuntu"
}
cmd := exec.Command(debootstrapPath, "--variant=minbase", suite, rootfsDir, mirror)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func isUbuntuSuite(suite string) bool {
ubuntuSuites := []string{"jammy", "focal", "noble", "mantic", "lunar", "kinetic", "bionic", "xenial"}
for _, s := range ubuntuSuites {
if strings.EqualFold(suite, s) {
return true
}
}
return false
}
// ──────────────────────────────────────────────────────────────────────────────
// Internal: Process & OS helpers
// ──────────────────────────────────────────────────────────────────────────────
// processAlive checks if a process with the given PID is still running.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// Check /proc/<pid> — most reliable on Linux/Android
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
return err == nil
}
// Fallback: signal 0 check
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
// detectOS reads /etc/os-release from a rootfs and returns the PRETTY_NAME.
func detectOS(rootfsDir string) string {
osReleasePath := filepath.Join(rootfsDir, "etc", "os-release")
f, err := os.Open(osReleasePath)
if err != nil {
return "-"
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "PRETTY_NAME=") {
val := strings.TrimPrefix(line, "PRETTY_NAME=")
return strings.Trim(val, "\"")
}
}
return "-"
}
// ──────────────────────────────────────────────────────────────────────────────
// Internal: File operations
// ──────────────────────────────────────────────────────────────────────────────
// copyFile copies a single file from src to dst, preserving permissions.
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return fmt.Errorf("stat %s: %w", src, err)
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return fmt.Errorf("copy %s → %s: %w", src, dst, err)
}
return nil
}
// copyDir recursively copies a directory tree from src to dst using cp -a.
// Uses the system cp command for reliability (preserves permissions, symlinks,
// hard links, special files) — same approach as the systemd backend.
func copyDir(src, dst string) error {
// Ensure destination exists
if err := os.MkdirAll(dst, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", dst, err)
}
// Use cp -a for atomic, permission-preserving copy
// The trailing /. copies contents into dst rather than creating src as a subdirectory
cmd := exec.Command("cp", "-a", src+"/.", dst)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("cp -a %s → %s: %s: %w", src, dst, strings.TrimSpace(string(out)), err)
}
return nil
}

View File

@@ -0,0 +1,347 @@
package proot
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/armoredgate/volt/pkg/backend"
"gopkg.in/yaml.v3"
)
func TestName(t *testing.T) {
b := New()
if b.Name() != "proot" {
t.Errorf("expected name 'proot', got %q", b.Name())
}
}
func TestCapabilities(t *testing.T) {
b := New()
if b.SupportsVMs() {
t.Error("proot should not support VMs")
}
if b.SupportsServices() {
t.Error("proot should not support services")
}
if !b.SupportsNetworking() {
t.Error("proot should support basic networking")
}
if b.SupportsTuning() {
t.Error("proot should not support tuning")
}
}
func TestInit(t *testing.T) {
tmpDir := t.TempDir()
b := New()
if err := b.Init(tmpDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify directory structure
for _, sub := range []string{"containers", "images", "tmp"} {
path := filepath.Join(tmpDir, sub)
info, err := os.Stat(path)
if err != nil {
t.Errorf("expected directory %s to exist: %v", sub, err)
continue
}
if !info.IsDir() {
t.Errorf("expected %s to be a directory", sub)
}
}
// Verify tmp has 0777 permissions
info, _ := os.Stat(filepath.Join(tmpDir, "tmp"))
if info.Mode().Perm() != 0777 {
t.Errorf("expected tmp perms 0777, got %o", info.Mode().Perm())
}
}
func TestCreateAndDelete(t *testing.T) {
tmpDir := t.TempDir()
b := New()
b.Init(tmpDir)
// Create a container
opts := backend.CreateOptions{
Name: "test-container",
Memory: "512M",
CPU: 1,
Env: []string{"FOO=bar"},
Ports: []backend.PortMapping{{HostPort: 8080, ContainerPort: 80, Protocol: "tcp"}},
}
if err := b.Create(opts); err != nil {
t.Fatalf("Create failed: %v", err)
}
// Verify container directory structure
cDir := filepath.Join(tmpDir, "containers", "test-container")
for _, sub := range []string{"rootfs", "logs"} {
path := filepath.Join(cDir, sub)
if _, err := os.Stat(path); err != nil {
t.Errorf("expected %s to exist: %v", sub, err)
}
}
// Verify state.json
stateData, err := os.ReadFile(filepath.Join(cDir, "state.json"))
if err != nil {
t.Fatalf("failed to read state.json: %v", err)
}
var state containerState
if err := json.Unmarshal(stateData, &state); err != nil {
t.Fatalf("failed to parse state.json: %v", err)
}
if state.Name != "test-container" {
t.Errorf("expected name 'test-container', got %q", state.Name)
}
if state.Status != "created" {
t.Errorf("expected status 'created', got %q", state.Status)
}
// Verify config.yaml
cfgData, err := os.ReadFile(filepath.Join(cDir, "config.yaml"))
if err != nil {
t.Fatalf("failed to read config.yaml: %v", err)
}
var cfg containerConfig
if err := yaml.Unmarshal(cfgData, &cfg); err != nil {
t.Fatalf("failed to parse config.yaml: %v", err)
}
if cfg.Memory != "512M" {
t.Errorf("expected memory '512M', got %q", cfg.Memory)
}
if len(cfg.Ports) != 1 || cfg.Ports[0].HostPort != 8080 {
t.Errorf("expected port mapping 8080:80, got %+v", cfg.Ports)
}
// Verify duplicate create fails
if err := b.Create(opts); err == nil {
t.Error("expected duplicate create to fail")
}
// List should return one container
containers, err := b.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(containers) != 1 {
t.Errorf("expected 1 container, got %d", len(containers))
}
// Inspect should work
info, err := b.Inspect("test-container")
if err != nil {
t.Fatalf("Inspect failed: %v", err)
}
if info.Status != "created" {
t.Errorf("expected status 'created', got %q", info.Status)
}
// Delete should work
if err := b.Delete("test-container", false); err != nil {
t.Fatalf("Delete failed: %v", err)
}
// Verify directory removed
if _, err := os.Stat(cDir); !os.IsNotExist(err) {
t.Error("expected container directory to be removed")
}
// List should be empty now
containers, err = b.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(containers) != 0 {
t.Errorf("expected 0 containers, got %d", len(containers))
}
}
func TestCopyOperations(t *testing.T) {
tmpDir := t.TempDir()
b := New()
b.Init(tmpDir)
// Create a container
opts := backend.CreateOptions{Name: "copy-test"}
if err := b.Create(opts); err != nil {
t.Fatalf("Create failed: %v", err)
}
// Create a source file on "host"
srcFile := filepath.Join(tmpDir, "host-file.txt")
os.WriteFile(srcFile, []byte("hello from host"), 0644)
// Copy to container
if err := b.CopyToContainer("copy-test", srcFile, "/etc/test.txt"); err != nil {
t.Fatalf("CopyToContainer failed: %v", err)
}
// Verify file exists in rootfs
containerFile := filepath.Join(tmpDir, "containers", "copy-test", "rootfs", "etc", "test.txt")
data, err := os.ReadFile(containerFile)
if err != nil {
t.Fatalf("file not found in container: %v", err)
}
if string(data) != "hello from host" {
t.Errorf("expected 'hello from host', got %q", string(data))
}
// Copy from container
dstFile := filepath.Join(tmpDir, "from-container.txt")
if err := b.CopyFromContainer("copy-test", "/etc/test.txt", dstFile); err != nil {
t.Fatalf("CopyFromContainer failed: %v", err)
}
data, err = os.ReadFile(dstFile)
if err != nil {
t.Fatalf("failed to read copied file: %v", err)
}
if string(data) != "hello from host" {
t.Errorf("expected 'hello from host', got %q", string(data))
}
}
func TestLogs(t *testing.T) {
tmpDir := t.TempDir()
b := New()
b.Init(tmpDir)
// Create a container
opts := backend.CreateOptions{Name: "log-test"}
b.Create(opts)
// Write some log lines
logDir := filepath.Join(tmpDir, "containers", "log-test", "logs")
logFile := filepath.Join(logDir, "current.log")
lines := "line1\nline2\nline3\nline4\nline5\n"
os.WriteFile(logFile, []byte(lines), 0644)
// Full logs
content, err := b.Logs("log-test", backend.LogOptions{})
if err != nil {
t.Fatalf("Logs failed: %v", err)
}
if content != lines {
t.Errorf("expected full log content, got %q", content)
}
// Tail 2 lines
content, err = b.Logs("log-test", backend.LogOptions{Tail: 2})
if err != nil {
t.Fatalf("Logs tail failed: %v", err)
}
// Last 2 lines of "line1\nline2\nline3\nline4\nline5\n" split gives 6 elements
// (last is empty after trailing \n), so tail 2 gives "line5\n"
if content == "" {
t.Error("expected some tail output")
}
// No logs available
content, err = b.Logs("nonexistent", backend.LogOptions{})
if err == nil {
// Container doesn't exist, should get error from readState
// but Logs reads file directly, so check
}
}
func TestAvailable(t *testing.T) {
b := New()
// Just verify it doesn't panic
_ = b.Available()
}
func TestProcessAlive(t *testing.T) {
// PID 1 (init) should be alive
if !processAlive(1) {
t.Error("expected PID 1 to be alive")
}
// PID 0 should not be alive
if processAlive(0) {
t.Error("expected PID 0 to not be alive")
}
// Very large PID should not be alive
if processAlive(999999999) {
t.Error("expected PID 999999999 to not be alive")
}
}
func TestDetectOS(t *testing.T) {
tmpDir := t.TempDir()
// No os-release file
result := detectOS(tmpDir)
if result != "-" {
t.Errorf("expected '-' for missing os-release, got %q", result)
}
// Create os-release
etcDir := filepath.Join(tmpDir, "etc")
os.MkdirAll(etcDir, 0755)
osRelease := `NAME="Ubuntu"
VERSION="24.04 LTS (Noble Numbat)"
ID=ubuntu
PRETTY_NAME="Ubuntu 24.04 LTS"
VERSION_ID="24.04"
`
os.WriteFile(filepath.Join(etcDir, "os-release"), []byte(osRelease), 0644)
result = detectOS(tmpDir)
if result != "Ubuntu 24.04 LTS" {
t.Errorf("expected 'Ubuntu 24.04 LTS', got %q", result)
}
}
func TestEntrypointDetection(t *testing.T) {
tmpDir := t.TempDir()
b := New()
cfg := &containerConfig{Name: "test"}
// Empty rootfs — should fallback to /bin/sh
ep, args := b.detectEntrypoint(tmpDir, cfg)
if ep != "/bin/sh" {
t.Errorf("expected /bin/sh fallback, got %q", ep)
}
if len(args) != 0 {
t.Errorf("expected no args for /bin/sh, got %v", args)
}
// Create /init
initPath := filepath.Join(tmpDir, "init")
os.WriteFile(initPath, []byte("#!/bin/sh\nexec /bin/sh"), 0755)
ep, _ = b.detectEntrypoint(tmpDir, cfg)
if ep != "/init" {
t.Errorf("expected /init, got %q", ep)
}
// Remove /init, create nginx
os.Remove(initPath)
nginxDir := filepath.Join(tmpDir, "usr", "sbin")
os.MkdirAll(nginxDir, 0755)
os.WriteFile(filepath.Join(nginxDir, "nginx"), []byte(""), 0755)
ep, args = b.detectEntrypoint(tmpDir, cfg)
if ep != "/usr/sbin/nginx" {
t.Errorf("expected /usr/sbin/nginx, got %q", ep)
}
// With port mapping, should use shell wrapper
cfg.Ports = []backend.PortMapping{{HostPort: 8080, ContainerPort: 80}}
ep, args = b.detectEntrypoint(tmpDir, cfg)
if ep != "/bin/sh" {
t.Errorf("expected /bin/sh wrapper for nginx with ports, got %q", ep)
}
if len(args) != 2 || args[0] != "-c" {
t.Errorf("expected [-c <shellcmd>] for nginx wrapper, got %v", args)
}
}