370 lines
14 KiB
Go
Executable File
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)
|
|
} |