360 lines
13 KiB
Go
Executable File
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
|
|
} |