// 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 }