Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/podman/podman.go

Raw
package podman

import (
	"context"
	"io"
	"os"
	"os/exec"
	"sort"
	"strconv"
	"strings"
	"time"
)

type Mount struct {
	Source   string
	Target   string
	ReadOnly bool
}

type Spec struct {
	Image     string
	Workspace string
	Memory    string
	CPUs      string
	Env       map[string]string
	EnvFile   string
	Mounts    []Mount
	Commands  []string
	Timeout   time.Duration
}

func BuildArgs(spec Spec) []string {
	args := []string{
		"run",
		"--rm",
		"--pull=missing",
		"--userns=keep-id",
		"--cap-drop=all",
		"--security-opt=no-new-privileges",
		"--pids-limit=1024",
		"--memory=" + spec.Memory,
		"--cpus=" + spec.CPUs,
		"--timeout=" + strconv.FormatInt(int64((spec.Timeout+time.Second-1)/time.Second), 10),
		"--workdir=/work",
		"--volume", spec.Workspace + ":/work:Z",
	}
	for _, mount := range spec.Mounts {
		mode := ":Z"
		if mount.ReadOnly {
			mode = ":ro,Z"
		}
		args = append(args, "--volume", mount.Source+":"+mount.Target+mode)
	}
	if spec.EnvFile != "" {
		args = append(args, "--env-file", spec.EnvFile)
	}
	keys := make([]string, 0, len(spec.Env))
	for key := range spec.Env {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		args = append(args, "--env", key)
	}
	args = append(args, "--", spec.Image, "/bin/sh", "-c", StepScript(spec.Commands))
	return args
}

// Environment returns only frozen child values named by BuildArgs.
// Host environment never becomes container environment.
func Environment(spec Spec) []string {
	return environment(spec.Env)
}

// StepScript wraps each command in a step marker so logs show per-command
// wall time. Commands already run through /bin/sh -c; eval keeps that trust
// boundary unchanged.
func StepScript(commands []string) string {
	var b strings.Builder
	b.WriteString("STEP=0\nTOTAL=")
	b.WriteString(strconv.Itoa(len(commands)))
	b.WriteString("\nstep() {\n  STEP=$((STEP+1))\n  printf '[luci] step %d/%d: %s\\n' \"$STEP\" \"$TOTAL\" \"$1\"\n  START=$(date +%s)\n  eval \"$1\"\n  RC=$?\n  END=$(date +%s)\n  if [ \"$RC\" -ne 0 ]; then\n    printf '[luci] step %d/%d failed after %ds\\n' \"$STEP\" \"$TOTAL\" \"$((END-START))\"\n    exit \"$RC\"\n  fi\n  printf '[luci] step %d/%d done in %ds\\n' \"$STEP\" \"$TOTAL\" \"$((END-START))\"\n}\n")
	for _, command := range commands {
		b.WriteString("step '")
		b.WriteString(strings.ReplaceAll(command, "'", `'\''`))
		b.WriteString("'\n")
	}
	return b.String()
}

type Executor interface {
	Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error
}

// EnvironmentExecutor runs Podman with a caller-supplied child environment.
// Callers passing BuildArgs(spec) must pass Environment(spec) here.
type EnvironmentExecutor interface {
	Executor
	RunWithEnv(ctx context.Context, args, env []string, stdout io.Writer, stderr io.Writer) error
}

type CLI struct {
	Socket string
}

func (c CLI) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
	return c.RunWithEnv(ctx, args, os.Environ(), stdout, stderr)
}

func (c CLI) RunWithEnv(ctx context.Context, args, env []string, stdout io.Writer, stderr io.Writer) error {
	values := make(map[string]string, len(env)+1)
	for _, entry := range env {
		key, value, _ := strings.Cut(entry, "=")
		values[key] = value
	}
	if c.Socket != "" {
		values["CONTAINER_HOST"] = c.Socket
	}
	cmd := exec.CommandContext(ctx, "podman", args...)
	cmd.Env = environment(values)
	cmd.Stdout = stdout
	cmd.Stderr = stderr
	return cmd.Run()
}

func environment(values map[string]string) []string {
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	env := make([]string, 0, len(keys))
	for _, key := range keys {
		env = append(env, key+"="+values[key])
	}
	return env
}