Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/podman/podman.go

Raw
package podman

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

type Mount struct {
	Source   string
	Target   string
	ReadOnly bool
}

type Spec struct {
	Image string
	// Pull is an explicit Podman pull policy. Empty retains normal tag refresh.
	Pull      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 {
	pull := spec.Pull
	if pull == "" {
		pull = "always"
	}
	args := []string{
		"run",
		"--rm",
		"--pull=" + pull,
		"--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
}

// BuildSpec is intentionally narrower than a job: build files never receive
// job env, secrets, mounts, sockets, or caller-selected Podman flags.
type BuildSpec struct {
	Containerfile string
	Context       string
	Tag           string
	IIDFile       string
	AuthFile      string
	Memory        string
	CPUs          string
}

// BuildArgs uses flags documented by `podman build --help`: resource flags
// constrain RUN containers even when this client targets a Podman socket.
func BuildArgsForContainerfile(spec BuildSpec) ([]string, error) {
	cpus, err := strconv.ParseFloat(spec.CPUs, 64)
	if err != nil || cpus <= 0 || math.IsNaN(cpus) || math.IsInf(cpus, 0) {
		return nil, fmt.Errorf("invalid build CPUs %q", spec.CPUs)
	}
	quota := int64(cpus * 100000)
	if quota < 1 {
		quota = 1
	}
	args := []string{
		"build",
		"--pull=always",
		"--layers",
		"--network=private",
		"--http-proxy=false",
		"--memory=" + spec.Memory,
		"--cpu-period=100000",
		"--cpu-quota=" + strconv.FormatInt(quota, 10),
		"--ulimit=nproc=1024:1024",
		"--security-opt=no-new-privileges",
		"--label=" + BuildCacheLabel,
		"--layer-label=" + BuildCacheLabel,
		"--tag=" + spec.Tag,
		"--iidfile=" + spec.IIDFile,
		"--file=" + spec.Containerfile,
	}
	if spec.AuthFile != "" {
		args = append(args, "--authfile="+spec.AuthFile)
	}
	args = append(args, spec.Context)
	return args, nil
}

// RemoveImageArgs drops only a caller-owned tag. Without --force, Podman
// refuses cleanup that would disrupt a container still using the image.
func RemoveImageArgs(tag string) []string { return []string{"image", "rm", tag} }

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