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 }