sorcery-go/pkg/runtime/podman.go

370 lines
14 KiB
Go
Executable File

// Podman runtime adapter for Sorcery-Go Sanctums.
//
// Uses the Podman CLI (podman create, podman start, podman stop, etc.)
// to manage OCI containers. Podman provides rootless containers,
// OCI-compliant images, and is compatible with Docker workflows
// while being daemonless and more secure by default.
//
// Podman containers use Linux namespaces + cgroups for isolation,
// same as LXC, but are managed through the OCI runtime (crun/runc).
// The eBPF enforcer attaches at the cgroup level, so it works
// identically for both LXC and Podman.
package runtime
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// PodmanRuntime manages Podman OCI containers.
type PodmanRuntime struct {
binPath string
rootless bool
}
// NewPodmanRuntime creates a Podman runtime adapter.
func NewPodmanRuntime() *PodmanRuntime {
return &PodmanRuntime{
binPath: "podman",
}
}
func (r *PodmanRuntime) Type() Type { return RuntimePodman }
func (r *PodmanRuntime) Name() string { return "Podman OCI containers" }
// Probe checks that podman is available.
func (r *PodmanRuntime) Probe() error {
cmd := exec.Command("podman", "--version")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: not found in PATH: %w\n%s", err, string(out))
}
// Check if running rootless (version string contains "rootless").
version := strings.TrimSpace(string(out))
r.rootless = strings.Contains(version, "rootless")
return nil
}
// Create provisions a new Podman container.
func (r *PodmanRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
args := []string{"create", "--name", opts.Name}
if opts.Image != "" {
args = append(args, opts.Image)
}
// Network configuration.
if opts.NetworkConfig != nil {
switch opts.NetworkConfig.Type {
case "none":
args = append(args, "--network", "none")
case "host":
args = append(args, "--network", "host")
case "bridge":
if opts.NetworkConfig.Bridge != "" {
args = append(args, "--network", opts.NetworkConfig.Bridge)
}
if opts.NetworkConfig.IP != "" {
args = append(args, "--ip", opts.NetworkConfig.IP)
}
if opts.NetworkConfig.MACAddress != "" {
args = append(args, "--mac-address", opts.NetworkConfig.MACAddress)
}
}
}
// Bind mounts.
for _, m := range opts.BindMounts {
args = append(args, "--mount", "type=bind,source="+m.HostPath+",destination="+m.ContainerPath+",readonly="+strconv.FormatBool(m.ReadOnly))
}
// Environment variables.
for k, v := range opts.EnvVars {
args = append(args, "-e", k+"="+v)
}
// Resource limits.
if opts.MemoryMB > 0 {
args = append(args, "--memory", fmt.Sprintf("%dm", opts.MemoryMB))
}
// Capabilities: drop all, then add back if specified.
args = append(args, "--cap-drop", "ALL")
for _, cap := range opts.Caps {
args = append(args, "--cap-add", cap)
}
// SECURITY: Disable default AppArmor and seccomp confinement.
// This is intentional — the sorcery-go eBPF Tomb Guard LSM handles
// mandatory access control (MAC) enforcement, making the container-level
// profiles redundant. Re-enabling them would conflict with the eBPF
// enforcer's cgroup-level hook attachments.
args = append(args, "--security-opt", "apparmor=unconfined")
args = append(args, "--security-opt", "seccomp=unconfined")
// Architecture.
if opts.Arch != "" {
args = append(args, "--arch", opts.Arch)
}
// Extra podman-specific options.
for k, v := range opts.ExtraConfig {
args = append(args, k, v)
}
// If no image was specified and RootFS is provided, use a scratch image.
if opts.Image == "" && opts.RootFS != "" {
args = append(args, "scratch")
}
cmd := exec.CommandContext(ctx, "podman", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("podman: create %s: %w\n%s", opts.Name, err, string(out))
}
// Parse container ID from output (podman create prints the ID).
containerID := strings.TrimSpace(string(out))
if containerID == "" {
containerID = opts.Name
}
return containerID, nil
}
// Start starts a Podman container.
func (r *PodmanRuntime) Start(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "podman", "start", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: start %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Stop stops a Podman container.
func (r *PodmanRuntime) Stop(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "podman", "stop", "-t", "30", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: stop %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Freeze suspends a Podman container via cgroup v2 freezer.
func (r *PodmanRuntime) Freeze(ctx context.Context, sanctumID string) error {
// Podman containers can be frozen via cgroups or podman pause.
cgPath := r.CgroupPath(sanctumID)
if cgPath != "" {
freezeFile := cgPath + "/cgroup.freeze"
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("1"), 0644); err == nil {
return nil
}
}
}
// Fallback: podman pause.
cmd := exec.CommandContext(ctx, "podman", "pause", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: freeze %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Thaw resumes a paused Podman container.
func (r *PodmanRuntime) Thaw(ctx context.Context, sanctumID string) error {
cgPath := r.CgroupPath(sanctumID)
if cgPath != "" {
freezeFile := cgPath + "/cgroup.freeze"
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("0"), 0644); err == nil {
return nil
}
}
}
// Fallback: podman unpause.
cmd := exec.CommandContext(ctx, "podman", "unpause", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: thaw %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Destroy removes a Podman container.
func (r *PodmanRuntime) Destroy(ctx context.Context, sanctumID string) error {
// Force remove (handles running containers too).
cmd := exec.CommandContext(ctx, "podman", "rm", "-f", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: destroy %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Exec runs a command inside a Podman container.
func (r *PodmanRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
args := append([]string{"exec", sanctumID}, command...)
cmd := exec.CommandContext(ctx, "podman", 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 a Podman container.
func (r *PodmanRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
cmd := exec.CommandContext(ctx, "podman", "inspect", sanctumID, "--format", "json")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("podman: inspect %s: %w", sanctumID, err)
}
var containers []struct {
ID string `json:"Id"`
Name string `json:"Name"`
State string `json:"State"`
Status string `json:"Status"`
PID int `json:"Pid"`
IPAddress string `json:"IPAddress"`
Created string `json:"Created"`
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
}
if err := json.Unmarshal(out, &containers); err != nil || len(containers) == 0 {
return nil, fmt.Errorf("podman: failed to parse inspect output for %s", sanctumID)
}
c := containers[0]
created, _ := time.Parse(time.RFC3339Nano, c.Created)
return &SanctumInfo{
ID: c.ID[:12],
Name: c.Name,
Runtime: RuntimePodman,
Status: parsePodmanState(c.State),
Arch: "",
IP: c.IPAddress,
PID: uint32(c.PID),
Cgroup: r.CgroupPath(sanctumID),
Created: created,
Metadata: c.Config.Labels,
}, nil
}
// List returns all Podman containers.
func (r *PodmanRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
cmd := exec.CommandContext(ctx, "podman", "ps", "-a", "--format", "json")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("podman: list: %w", err)
}
var containers []struct {
ID string `json:"Id"`
Name string `json:"Names"`
State string `json:"State"`
Image string `json:"Image"`
PID int `json:"Pid"`
IPAddress string `json:"IPAddress"`
Created int64 `json:"CreatedAt"`
}
if err := json.Unmarshal(out, &containers); err != nil {
return nil, fmt.Errorf("podman: failed to parse ps output: %w", err)
}
var infos []*SanctumInfo
for _, c := range containers {
names := strings.Split(c.Name, ",")
name := names[0]
infos = append(infos, &SanctumInfo{
ID: c.ID[:12],
Name: name,
Runtime: RuntimePodman,
Status: parsePodmanState(c.State),
IP: c.IPAddress,
PID: uint32(c.PID),
Cgroup: r.CgroupPath(c.ID[:12]),
Created: time.Unix(c.Created, 0),
})
}
return infos, nil
}
// CgroupPath returns the cgroup v2 path for a Podman container.
// Podman places containers under the systemd scope hierarchy.
func (r *PodmanRuntime) CgroupPath(sanctumID string) string {
// Podman uses libpod-cni or libpod-podman scopes.
// Try common cgroup v2 paths for podman containers.
candidates := []string{
// Rootless podman
"/sys/fs/cgroup/user.slice/user-" + strconv.Itoa(os.Getuid()) + ".slice/user@" + strconv.Itoa(os.Getuid()) + ".service/" + "libpod-" + sanctumID + ".scope",
// Rootful podman
"/sys/fs/cgroup/machine.slice/libpod-" + sanctumID + ".scope",
"/sys/fs/cgroup/system.slice/libpod-" + sanctumID + ".scope",
// Podman with cgroupns=host
"/sys/fs/cgroup/libpod_parent/libpod-" + sanctumID + ".scope",
}
for _, p := range candidates {
if _, err := statPath(p + "/cgroup.freeze"); err == nil {
return p
}
}
// Try to find by container name (podman uses container name in some paths).
nameCandidates := []string{
"/sys/fs/cgroup/machine.slice/podman-" + sanctumID + ".scope",
"/sys/fs/cgroup/machine.slice/libpod_parent-" + sanctumID + ".scope",
}
for _, p := range nameCandidates {
if _, err := statPath(p + "/cgroup.freeze"); err == nil {
return p
}
}
// Try PID-based lookup.
return cgroupV2PathByPID(sanctumID)
}
// podmanStates maps lowercase Podman state strings to SanctumStatus.
// Falls through to StatusUnknown for unrecognized states.
var podmanStates = map[string]SanctumStatus{
"running": StatusRunning,
"stopped": StatusStopped,
"exited": StatusStopped,
"dead": StatusStopped,
"paused": StatusFrozen,
"created": StatusCreating,
}
func parsePodmanState(s string) SanctumStatus {
if st, ok := podmanStates[strings.ToLower(s)]; ok {
return st
}
return StatusUnknown
}
// statPath wraps os.Stat for use in cgroup path detection.
func statPath(p string) (os.FileInfo, error) {
return os.Stat(p)
}