sorcery-go/pkg/runtime/lxc.go

360 lines
13 KiB
Go
Executable File

// LXC runtime adapter for Sorcery-Go Sanctums.
//
// Uses the lxc-tools CLI (lxc-create, lxc-start, lxc-stop, lxc-destroy,
// lxc-freeze, lxc-unfreeze, lxc-execute, lxc-info, lxc-ls) to manage
// system containers. This is the original runtime that Sorcery-Go was
// designed around.
//
// LXC containers share the host kernel and use Linux namespaces for
// isolation. They are ideal for high-density deployment where the
// performance overhead of virtualization is undesirable.
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// LXCRuntime manages LXC system containers.
type LXCRuntime struct {
binPath string // path to lxc-* tools (e.g., "/usr/bin")
configDir string // LXC config directory (default: /var/lib/lxc)
lxcInclude string // path to common.conf include directory
}
// NewLXCRuntime creates an LXC runtime adapter. It auto-detects the
// lxc-tools installation path.
func NewLXCRuntime() *LXCRuntime {
return &LXCRuntime{
binPath: "/usr/bin",
configDir: "/var/lib/lxc",
}
}
func (r *LXCRuntime) Type() Type { return RuntimeLXC }
func (r *LXCRuntime) Name() string { return "LXC system containers" }
// Probe checks that lxc-create is available on the host.
func (r *LXCRuntime) Probe() error {
if _, err := exec.LookPath("lxc-create"); err != nil {
return fmt.Errorf("lxc: lxc-create not found in PATH: %w", err)
}
if _, err := exec.LookPath("lxc-start"); err != nil {
return fmt.Errorf("lxc: lxc-start not found in PATH: %w", err)
}
r.configDir = "/var/lib/lxc"
if d := os.Getenv("LXC_PATH"); d != "" {
r.configDir = d
}
return nil
}
// Create provisions a new LXC container.
func (r *LXCRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
args := []string{
"-n", opts.Name,
"-t", opts.Image, // template name (e.g., "download")
}
// For the "download" template, pass distro/arch/release.
if opts.Image == "download" {
args = append(args, "--")
if opts.Arch != "" {
args = append(args, "-a", opts.Arch)
}
// Allow setting distro/release via ExtraConfig.
if distro, ok := opts.ExtraConfig["distro"]; ok {
args = append(args, "-d", distro)
}
if release, ok := opts.ExtraConfig["release"]; ok {
args = append(args, "-r", release)
}
}
cmd := exec.CommandContext(ctx, "lxc-create", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("lxc: create %s: %w\n%s", opts.Name, err, string(out))
}
// Write custom config overrides to the container's config file.
configPath := filepath.Join(r.configDir, opts.Name, "config")
if opts.NetworkConfig != nil {
if err := r.appendConfig(configPath, buildLXCNetworkConfig(opts.NetworkConfig)); err != nil {
return "", fmt.Errorf("lxc: write network config: %w", err)
}
}
if opts.RootFS != "" {
if err := r.appendConfig(configPath, fmt.Sprintf("lxc.rootfs.path = %s\n", opts.RootFS)); err != nil {
return "", fmt.Errorf("lxc: write rootfs config: %w", err)
}
}
// SECURITY: eBPF replaces AppArmor — set profile to unconfined since eBPF handles MAC.
if err := r.appendConfig(configPath, "lxc.apparmor.profile = unconfined\n"); err != nil {
return "", fmt.Errorf("lxc: write apparmor config: %w", err)
}
return opts.Name, nil
}
// Start boots an LXC container.
func (r *LXCRuntime) Start(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "lxc-start", "-n", sanctumID, "-d")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: start %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Stop gracefully stops an LXC container.
func (r *LXCRuntime) Stop(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "lxc-stop", "-n", sanctumID, "-t", "30")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: stop %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Freeze suspends an LXC container via cgroup v2 freezer.
func (r *LXCRuntime) Freeze(ctx context.Context, sanctumID string) error {
// Try cgroup v2 first (modern approach).
if cgPath := r.CgroupPath(sanctumID); cgPath != "" {
freezeFile := filepath.Join(cgPath, "cgroup.freeze")
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("1"), 0644); err == nil {
return nil
}
}
}
// Fall back to lxc-freeze.
cmd := exec.CommandContext(ctx, "lxc-freeze", "-n", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: freeze %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Thaw resumes a frozen LXC container.
func (r *LXCRuntime) Thaw(ctx context.Context, sanctumID string) error {
// Try cgroup v2 first.
if cgPath := r.CgroupPath(sanctumID); cgPath != "" {
freezeFile := filepath.Join(cgPath, "cgroup.freeze")
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("0"), 0644); err == nil {
return nil
}
}
}
// Fall back to lxc-unfreeze.
cmd := exec.CommandContext(ctx, "lxc-unfreeze", "-n", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: thaw %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Destroy removes an LXC container.
func (r *LXCRuntime) Destroy(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "lxc-destroy", "-n", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: destroy %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Exec runs a command inside an LXC container.
func (r *LXCRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
args := append([]string{"-n", sanctumID, "--"}, command...)
cmd := exec.CommandContext(ctx, "lxc-execute", args...)
if len(stdin) > 0 {
cmd.Stdin = strings.NewReader(string(stdin))
}
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return &ExecResult{
ExitCode: exitCode(err),
Stdout: []byte(stdout.String()),
Stderr: []byte(stderr.String()),
}, err
}
// Status returns the current state of an LXC container.
func (r *LXCRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
// Use lxc-info to get container state.
cmd := exec.CommandContext(ctx, "lxc-info", "-n", sanctumID, "-j")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("lxc: status %s: %w", sanctumID, err)
}
info := &SanctumInfo{
ID: sanctumID,
Name: sanctumID,
Runtime: RuntimeLXC,
}
// Parse lxc-info JSON output.
var lxcInfo struct {
State string `json:"state"`
PID int `json:"pid"`
IPs []struct {
Interface string `json:"interface"`
Address string `json:"address"`
Family string `json:"family"`
} `json:"ips"`
}
if err := json.Unmarshal(out, &lxcInfo); err == nil {
info.Status = parseLXCState(lxcInfo.State)
info.PID = uint32(lxcInfo.PID)
if len(lxcInfo.IPs) > 0 {
info.IP = lxcInfo.IPs[0].Address
}
}
info.Cgroup = r.CgroupPath(sanctumID)
info.RootFS = filepath.Join(r.configDir, sanctumID, "rootfs")
info.Created = time.Time{} // LXC doesn't expose creation time easily
return info, nil
}
// List returns all LXC containers.
func (r *LXCRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
cmd := exec.CommandContext(ctx, "lxc-ls", "-f", "-F", "name,state,pid,ipv4")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("lxc: list: %w", err)
}
var infos []*SanctumInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 1 || fields[0] == "" {
continue
}
info := &SanctumInfo{
ID: fields[0],
Name: fields[0],
Runtime: RuntimeLXC,
Status: StatusUnknown,
}
if len(fields) > 1 {
info.Status = parseLXCState(fields[1])
}
if len(fields) > 2 {
pid, _ := strconv.ParseUint(fields[2], 10, 32)
info.PID = uint32(pid)
}
if len(fields) > 3 && fields[3] != "-" {
info.IP = fields[3]
}
info.Cgroup = r.CgroupPath(fields[0])
info.RootFS = filepath.Join(r.configDir, fields[0], "rootfs")
infos = append(infos, info)
}
return infos, nil
}
// CgroupPath returns the cgroup v2 path for an LXC container.
// Checks common cgroup hierarchy locations.
func (r *LXCRuntime) CgroupPath(sanctumID string) string {
candidates := []string{
filepath.Join("/sys/fs/cgroup/lxc", sanctumID),
filepath.Join("/sys/fs/cgroup/lxc.payload", sanctumID),
filepath.Join("/sys/fs/cgroup/system.slice", "lxc-"+sanctumID+".service"),
filepath.Join("/sys/fs/cgroup/machine.slice", "lxc-"+sanctumID+".service"),
}
for _, p := range candidates {
if _, err := os.Stat(filepath.Join(p, "cgroup.freeze")); err == nil {
return p
}
}
// Try to find it via the container's init PID.
if pidPath := cgroupV2PathByPID(sanctumID); pidPath != "" {
return pidPath
}
return ""
}
// --- helpers ---
func (r *LXCRuntime) appendConfig(configPath, content string) error {
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("lxc: open config %s: %w", configPath, err)
}
defer f.Close()
if _, err := f.WriteString("\n# --- Sorcery-Go auto-generated ---\n"); err != nil {
return fmt.Errorf("lxc: write config header %s: %w", configPath, err)
}
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("lxc: write config %s: %w", configPath, err)
}
return nil
}
func buildLXCNetworkConfig(nc *NetworkConfig) string {
var b strings.Builder
switch nc.Type {
case "none":
b.WriteString("lxc.net.0.type = empty\n")
case "host":
b.WriteString("lxc.net.0.type = none\n")
default: // "bridge"
b.WriteString("lxc.net.0.type = veth\n")
b.WriteString("lxc.net.0.flags = up\n")
if nc.Bridge != "" {
b.WriteString(fmt.Sprintf("lxc.net.0.link = %s\n", nc.Bridge))
}
if nc.MACAddress != "" {
b.WriteString(fmt.Sprintf("lxc.net.0.hwaddr = %s\n", nc.MACAddress))
}
if nc.IP != "" {
b.WriteString(fmt.Sprintf("lxc.net.0.ipv4.address = %s\n", nc.IP))
}
}
return b.String()
}
// lxcStates maps uppercase LXC state strings to SanctumStatus.
var lxcStates = map[string]SanctumStatus{
"RUNNING": StatusRunning,
"STOPPED": StatusStopped,
"FROZEN": StatusFrozen,
}
func parseLXCState(s string) SanctumStatus {
if st, ok := lxcStates[strings.ToUpper(s)]; ok {
return st
}
return StatusUnknown
}
func exitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return 1
}