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
1000 lines
32 KiB
Go
1000 lines
32 KiB
Go
/*
|
|
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
|
|
}
|