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:
999
pkg/backend/proot/proot.go
Normal file
999
pkg/backend/proot/proot.go
Normal 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
|
||||
}
|
||||
347
pkg/backend/proot/proot_test.go
Normal file
347
pkg/backend/proot/proot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user