Refactor installation and runner scripts for enhanced usability and security
- Updated the installation script to clarify that production wrappers do not include fixed test secrets, improving user guidance for local testing. - Modified the jiggablend-manager and jiggablend-runner scripts to remove fixed test configurations, emphasizing the use of local test credentials via `make init-test`. - Enhanced error handling in the runner script to ensure that an API key is provided, improving security and user feedback. - Added new flags and options for the runner, including sandboxing capabilities and GPU ray tracing control, enhancing flexibility for users. - Improved README documentation to reflect changes in usage and configuration, ensuring users have clear instructions for setup and execution.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bind describes a host path to expose inside the sandbox.
|
||||
type Bind struct {
|
||||
Host string
|
||||
// Dest is the path inside the sandbox (usually same as Host for absolute paths).
|
||||
Dest string
|
||||
// ReadOnly is true for library trees and Blender installs.
|
||||
ReadOnly bool
|
||||
// Dev is true for device nodes / device trees (podman --device or -v for dirs).
|
||||
Dev bool
|
||||
// Optional means skip if host path missing (no error).
|
||||
Optional bool
|
||||
}
|
||||
|
||||
// GPUMounts returns host paths to expose for Cycles GPU backends based on detection.
|
||||
// forceCPU skips GPU-specific devices/libs (still may include generic /dev via backend).
|
||||
func GPUMounts(hasAMD, hasNVIDIA, hasIntel, forceCPU bool) []Bind {
|
||||
if forceCPU {
|
||||
return nil
|
||||
}
|
||||
var binds []Bind
|
||||
|
||||
// DRM render nodes used by AMD, Intel, and some NVIDIA EGL paths.
|
||||
if hasAMD || hasNVIDIA || hasIntel {
|
||||
binds = append(binds, Bind{Host: "/dev/dri", Dest: "/dev/dri", Dev: true, Optional: true})
|
||||
}
|
||||
|
||||
if hasAMD {
|
||||
binds = append(binds, Bind{Host: "/dev/kfd", Dest: "/dev/kfd", Dev: true, Optional: true})
|
||||
// Common ROCm install layouts
|
||||
for _, p := range []string{"/opt/rocm", "/usr/share/libdrm"} {
|
||||
binds = append(binds, Bind{Host: p, Dest: p, ReadOnly: true, Optional: true})
|
||||
}
|
||||
// Distro ROCm / amdgpu userspace libs often live under multiarch paths
|
||||
for _, p := range rocmLibHints() {
|
||||
binds = append(binds, Bind{Host: p, Dest: p, ReadOnly: true, Optional: true})
|
||||
}
|
||||
}
|
||||
|
||||
if hasNVIDIA {
|
||||
for _, name := range nvidiaDeviceNames() {
|
||||
p := filepath.Join("/dev", name)
|
||||
binds = append(binds, Bind{Host: p, Dest: p, Dev: true, Optional: true})
|
||||
}
|
||||
for _, p := range nvidiaLibHints() {
|
||||
binds = append(binds, Bind{Host: p, Dest: p, ReadOnly: true, Optional: true})
|
||||
}
|
||||
}
|
||||
|
||||
if hasIntel {
|
||||
for _, p := range intelLibHints() {
|
||||
binds = append(binds, Bind{Host: p, Dest: p, ReadOnly: true, Optional: true})
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueBinds(binds)
|
||||
}
|
||||
|
||||
func nvidiaDeviceNames() []string {
|
||||
// Fixed control nodes + any /dev/nvidiaN
|
||||
names := []string{"nvidiactl", "nvidia-uvm", "nvidia-uvm-tools", "nvidia-modeset"}
|
||||
entries, err := os.ReadDir("/dev")
|
||||
if err != nil {
|
||||
return names
|
||||
}
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if strings.HasPrefix(n, "nvidia") && !containsString(names, n) {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func nvidiaLibHints() []string {
|
||||
hints := []string{
|
||||
"/usr/lib/wsl/lib", // WSL NVIDIA
|
||||
"/usr/local/nvidia",
|
||||
"/usr/local/cuda",
|
||||
"/usr/lib/x86_64-linux-gnu",
|
||||
"/usr/lib64",
|
||||
}
|
||||
// Driver stores often under /usr/lib/libcuda* — parent dirs already covered.
|
||||
// Also scan common multiarch for libcuda.so
|
||||
for _, dir := range []string{"/usr/lib", "/usr/lib64", "/usr/lib/x86_64-linux-gnu", "/lib", "/lib64"} {
|
||||
if matchesAny(dir, "libcuda.so*") || matchesAny(dir, "libnvidia-*.so*") {
|
||||
hints = append(hints, dir)
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func rocmLibHints() []string {
|
||||
hints := []string{
|
||||
"/usr/lib/x86_64-linux-gnu",
|
||||
"/usr/lib64",
|
||||
"/opt/amdgpu",
|
||||
}
|
||||
// ROCm versioned trees under /opt/rocm-*
|
||||
if entries, err := os.ReadDir("/opt"); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), "rocm") {
|
||||
hints = append(hints, filepath.Join("/opt", e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func intelLibHints() []string {
|
||||
return []string{
|
||||
"/usr/lib/x86_64-linux-gnu",
|
||||
"/usr/lib64",
|
||||
"/usr/lib/intel-opencl",
|
||||
"/etc/OpenCL",
|
||||
}
|
||||
}
|
||||
|
||||
func matchesAny(dir, glob string) bool {
|
||||
m, err := filepath.Glob(filepath.Join(dir, glob))
|
||||
return err == nil && len(m) > 0
|
||||
}
|
||||
|
||||
func containsString(ss []string, s string) bool {
|
||||
for _, x := range ss {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueBinds(in []Bind) []Bind {
|
||||
seen := make(map[string]bool)
|
||||
var out []Bind
|
||||
for _, b := range in {
|
||||
key := b.Host + "|" + b.Dest + "|"
|
||||
if b.Dev {
|
||||
key += "d"
|
||||
}
|
||||
if b.ReadOnly {
|
||||
key += "r"
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExistingBinds filters to binds whose host path exists (or non-optional always kept for error reporting).
|
||||
func ExistingBinds(binds []Bind) []Bind {
|
||||
var out []Bind
|
||||
for _, b := range binds {
|
||||
if pathExists(b.Host) {
|
||||
out = append(out, b)
|
||||
continue
|
||||
}
|
||||
if !b.Optional {
|
||||
out = append(out, b) // caller may error
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package sandbox
|
||||
|
||||
import "os/exec"
|
||||
|
||||
type noneWrapper struct{}
|
||||
|
||||
func (w *noneWrapper) Name() string { return BackendNone }
|
||||
|
||||
func (w *noneWrapper) Available() error { return nil }
|
||||
|
||||
func (w *noneWrapper) Wrap(spec Spec) (*exec.Cmd, error) {
|
||||
cmd := exec.Command(spec.BlenderBinary, spec.Args...)
|
||||
cmd.Dir = spec.WorkDir
|
||||
if len(spec.Env) > 0 {
|
||||
cmd.Env = spec.Env
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package sandbox
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
func execAbs(p string) (string, error) {
|
||||
return filepath.Abs(p)
|
||||
}
|
||||
|
||||
// blenderRoot returns the directory that contains the blender binary
|
||||
// (the version extract root, e.g. .../blender-versions/4.5.7).
|
||||
func blenderRoot(blenderBinary string) string {
|
||||
return filepath.Dir(mustAbs(blenderBinary))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultPodmanImage = "registry.fedoraproject.org/fedora-minimal:41"
|
||||
|
||||
type podmanWrapper struct {
|
||||
allowNetwork bool
|
||||
image string
|
||||
}
|
||||
|
||||
func (w *podmanWrapper) Name() string { return BackendPodman }
|
||||
|
||||
func (w *podmanWrapper) Available() error {
|
||||
if _, err := LookPath("podman"); err != nil {
|
||||
return fmt.Errorf("podman not found in PATH: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *podmanWrapper) Wrap(spec Spec) (*exec.Cmd, error) {
|
||||
if err := w.Available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bin := mustAbs(spec.BlenderBinary)
|
||||
work := mustAbs(spec.WorkDir)
|
||||
home := mustAbs(spec.HomeDir)
|
||||
if home == "" {
|
||||
home = filepath.Join(work, "home")
|
||||
}
|
||||
root := blenderRoot(bin)
|
||||
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
"--security-opt", "label=disable",
|
||||
// Keep user groups for /dev/dri access (render/video)
|
||||
"--group-add", "keep-groups",
|
||||
}
|
||||
if !w.allowNetwork {
|
||||
args = append(args, "--network=none")
|
||||
}
|
||||
// Map to same UID so bind mounts are writable
|
||||
uid := os.Getuid()
|
||||
gid := os.Getgid()
|
||||
args = append(args, "--user", fmt.Sprintf("%d:%d", uid, gid))
|
||||
|
||||
// Thin OS image + host Blender tree + job dir
|
||||
args = append(args,
|
||||
"-v", root+":"+root+":ro",
|
||||
"-v", work+":"+work+":rw",
|
||||
)
|
||||
if home != work && !strings.HasPrefix(home, work+string(os.PathSeparator)) {
|
||||
_ = os.MkdirAll(home, 0755)
|
||||
args = append(args, "-v", home+":"+home+":rw")
|
||||
}
|
||||
|
||||
// System libs for GPU ICDs / dynamic linker (Blender tarball is mostly self-contained)
|
||||
for _, p := range []string{"/usr", "/lib", "/lib64", "/etc"} {
|
||||
if pathExists(p) {
|
||||
args = append(args, "-v", p+":"+p+":ro")
|
||||
}
|
||||
}
|
||||
|
||||
// GPU devices and extra lib roots
|
||||
for _, b := range ExistingBinds(GPUMounts(spec.HasAMD, spec.HasNVIDIA, spec.HasIntel, spec.ForceCPU)) {
|
||||
if !pathExists(b.Host) {
|
||||
continue
|
||||
}
|
||||
if b.Dev {
|
||||
// --device works for character devices; for /dev/dri use volume
|
||||
info, err := os.Stat(b.Host)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
args = append(args, "-v", b.Host+":"+b.Dest+":ro")
|
||||
} else {
|
||||
args = append(args, "--device", b.Host)
|
||||
}
|
||||
continue
|
||||
}
|
||||
mode := "ro"
|
||||
if !b.ReadOnly {
|
||||
mode = "rw"
|
||||
}
|
||||
args = append(args, "-v", b.Host+":"+b.Dest+":"+mode)
|
||||
}
|
||||
|
||||
// Environment
|
||||
for _, e := range spec.Env {
|
||||
args = append(args, "-e", e)
|
||||
}
|
||||
if home != "" {
|
||||
args = append(args, "-e", "HOME="+home)
|
||||
}
|
||||
|
||||
args = append(args, "--workdir", work)
|
||||
args = append(args, "--entrypoint", bin)
|
||||
args = append(args, w.image)
|
||||
// entrypoint is blender; remaining args are blender args
|
||||
args = append(args, spec.Args...)
|
||||
|
||||
cmd := exec.Command("podman", args...)
|
||||
cmd.Dir = work
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// PodmanImageDefault returns the default thin runtime image name.
|
||||
func PodmanImageDefault() string { return defaultPodmanImage }
|
||||
|
||||
// FormatUIDGID is a small helper for tests.
|
||||
func FormatUIDGID(uid, gid int) string {
|
||||
return strconv.Itoa(uid) + ":" + strconv.Itoa(gid)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package sandbox wraps Blender (and similar) invocations in optional isolation.
|
||||
//
|
||||
// Backends:
|
||||
// - none: run on the host (current behavior)
|
||||
// - podman: rootless podman container; Blender tarball is bind-mounted (not baked into an image)
|
||||
//
|
||||
// GPU access is provided by passing host device nodes and common userspace lib roots
|
||||
// discovered at wrap time (NVIDIA / AMD ROCm / Intel DRM), not by shipping per-version
|
||||
// Blender container images.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Backend names accepted by New and CLI flags.
|
||||
const (
|
||||
BackendNone = "none"
|
||||
BackendPodman = "podman"
|
||||
)
|
||||
|
||||
// Options configures sandbox construction.
|
||||
type Options struct {
|
||||
// Backend is none|podman (default none).
|
||||
Backend string
|
||||
// AllowNetwork keeps network in the sandbox (default false for podman).
|
||||
AllowNetwork bool
|
||||
// PodmanImage is used only for backend=podman. The image is a thin OS root;
|
||||
// Blender comes from a host bind of the versioned tarball tree.
|
||||
// Default: registry.fedoraproject.org/fedora-minimal:41
|
||||
PodmanImage string
|
||||
}
|
||||
|
||||
// Spec describes a Blender process to run under the sandbox.
|
||||
type Spec struct {
|
||||
// BlenderBinary is an absolute path to the blender executable.
|
||||
BlenderBinary string
|
||||
// Args are arguments after the binary (not including argv0).
|
||||
Args []string
|
||||
// WorkDir is the process working directory (job workspace); bind-mounted RW.
|
||||
WorkDir string
|
||||
// HomeDir is Blender HOME (usually WorkDir/home); bind-mounted RW.
|
||||
HomeDir string
|
||||
// Env is the full environment for the process (HOME, LD_LIBRARY_PATH, etc.).
|
||||
Env []string
|
||||
|
||||
// GPU hints from host detection (used to select device/lib binds).
|
||||
HasAMD bool
|
||||
HasNVIDIA bool
|
||||
HasIntel bool
|
||||
// ForceCPU skips GPU device binds when true.
|
||||
ForceCPU bool
|
||||
// AllowNetwork overrides Options.AllowNetwork when non-nil... kept simple: use Options only.
|
||||
}
|
||||
|
||||
// Wrapper turns a Spec into an *exec.Cmd ready to Start.
|
||||
type Wrapper interface {
|
||||
// Name returns the backend name.
|
||||
Name() string
|
||||
// Available reports whether required host tools exist.
|
||||
Available() error
|
||||
// Wrap builds the command. Callers own Start/Wait/pipes.
|
||||
Wrap(spec Spec) (*exec.Cmd, error)
|
||||
}
|
||||
|
||||
// New returns a sandbox Wrapper for the given options.
|
||||
func New(opts Options) (Wrapper, error) {
|
||||
backend := strings.ToLower(strings.TrimSpace(opts.Backend))
|
||||
if backend == "" {
|
||||
backend = BackendPodman
|
||||
}
|
||||
switch backend {
|
||||
case BackendNone:
|
||||
return &noneWrapper{}, nil
|
||||
case BackendPodman:
|
||||
img := opts.PodmanImage
|
||||
if img == "" {
|
||||
img = defaultPodmanImage
|
||||
}
|
||||
w := &podmanWrapper{allowNetwork: opts.AllowNetwork, image: img}
|
||||
return w, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown sandbox backend %q (want none or podman)", opts.Backend)
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeBackend validates and returns a canonical backend name.
|
||||
func NormalizeBackend(s string) (string, error) {
|
||||
b := strings.ToLower(strings.TrimSpace(s))
|
||||
if b == "" {
|
||||
return BackendPodman, nil
|
||||
}
|
||||
switch b {
|
||||
case BackendNone, BackendPodman:
|
||||
return b, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown sandbox backend %q (want none or podman)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// LookPath is os/exec.LookPath, overridable in tests.
|
||||
var LookPath = exec.LookPath
|
||||
|
||||
// pathExists reports whether path exists.
|
||||
func pathExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// mustAbs returns an absolute path or the original on error.
|
||||
func mustAbs(p string) string {
|
||||
if p == "" {
|
||||
return p
|
||||
}
|
||||
abs, err := absPath(p)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
// absPath is filepath.Abs, isolated for tests.
|
||||
var absPath = func(p string) (string, error) {
|
||||
return execAbs(p)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeBackend(t *testing.T) {
|
||||
got, err := NormalizeBackend("PODMAN")
|
||||
if err != nil || got != BackendPodman {
|
||||
t.Fatalf("got %q err %v", got, err)
|
||||
}
|
||||
if _, err := NormalizeBackend("bwrap"); err == nil {
|
||||
t.Fatal("expected error for removed bwrap backend")
|
||||
}
|
||||
if _, err := NormalizeBackend("firecracker"); err == nil {
|
||||
t.Fatal("expected error for unknown backend")
|
||||
}
|
||||
got, err = NormalizeBackend("")
|
||||
if err != nil || got != BackendPodman {
|
||||
t.Fatalf("empty -> podman, got %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoneWrap_Passthrough(t *testing.T) {
|
||||
w, err := New(Options{Backend: BackendNone})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := "/opt/blender/blender"
|
||||
cmd, err := w.Wrap(Spec{
|
||||
BlenderBinary: bin,
|
||||
Args: []string{"-b", "scene.blend"},
|
||||
WorkDir: "/tmp/job",
|
||||
Env: []string{"HOME=/tmp/job/home"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmd.Args) == 0 || cmd.Args[0] != bin {
|
||||
t.Fatalf("args[0]=%v path=%q", cmd.Args, cmd.Path)
|
||||
}
|
||||
if cmd.Dir != "/tmp/job" {
|
||||
t.Fatalf("Dir=%q", cmd.Dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUMounts_ForceCPUEmpty(t *testing.T) {
|
||||
m := GPUMounts(true, true, true, true)
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("force CPU should skip GPU mounts, got %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUMounts_AMDIncludesKFD(t *testing.T) {
|
||||
m := GPUMounts(true, false, false, false)
|
||||
var hosts []string
|
||||
for _, b := range m {
|
||||
hosts = append(hosts, b.Host)
|
||||
}
|
||||
joined := strings.Join(hosts, ",")
|
||||
if !strings.Contains(joined, "/dev/dri") {
|
||||
t.Fatalf("AMD mounts should include /dev/dri, got %v", hosts)
|
||||
}
|
||||
if !strings.Contains(joined, "/dev/kfd") {
|
||||
t.Fatalf("AMD mounts should include /dev/kfd, got %v", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUMounts_NVIDIAIncludesCtl(t *testing.T) {
|
||||
m := GPUMounts(false, true, false, false)
|
||||
found := false
|
||||
for _, b := range m {
|
||||
if strings.Contains(b.Host, "nvidia") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("NVIDIA mounts should include nvidia devices, got %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPodmanWrap_BuildsRunArgs(t *testing.T) {
|
||||
w := &podmanWrapper{allowNetwork: false, image: "example.com/thin:latest"}
|
||||
if err := w.Available(); err != nil {
|
||||
t.Skipf("podman not installed: %v", err)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
blendDir := filepath.Join(tmp, "bver")
|
||||
_ = os.MkdirAll(blendDir, 0755)
|
||||
bin := filepath.Join(blendDir, "blender")
|
||||
_ = os.WriteFile(bin, []byte("x"), 0755)
|
||||
job := filepath.Join(tmp, "job")
|
||||
_ = os.MkdirAll(job, 0755)
|
||||
|
||||
cmd, err := w.Wrap(Spec{
|
||||
BlenderBinary: bin,
|
||||
Args: []string{"-b", "x.blend"},
|
||||
WorkDir: job,
|
||||
HomeDir: filepath.Join(job, "home"),
|
||||
Env: []string{"HOME=" + filepath.Join(job, "home")},
|
||||
HasNVIDIA: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(cmd.Args, " ")
|
||||
if !strings.Contains(joined, "run") || !strings.Contains(joined, "--network=none") {
|
||||
t.Fatalf("expected podman run --network=none: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "example.com/thin:latest") {
|
||||
t.Fatalf("expected image in args: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "--entrypoint") || !strings.Contains(joined, bin) {
|
||||
t.Fatalf("expected entrypoint blender: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, blendDir) || !strings.Contains(joined, job) {
|
||||
t.Fatalf("expected volume binds: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_UnknownBackend(t *testing.T) {
|
||||
if _, err := New(Options{Backend: "bwrap"}); err == nil {
|
||||
t.Fatal("expected error for bwrap")
|
||||
}
|
||||
if _, err := New(Options{Backend: "gvisor"}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user