sorcery-go/pkg/sandbox/sandbox.go

297 lines
11 KiB
Go
Executable File

// Package sandbox isolates the build process using Linux kernel primitives.
//
// A Box is the "Hermetic Forge" — a private view of the operating system
// where the compiler believes it is writing to the live root, while every
// file it creates is silently captured in an OverlayFS "upper" directory.
//
// When the build finishes, Box.CollectManifest() walks the upper directory
// to produce the file list that becomes the Essence. No LD_PRELOAD, no
// eBPF — just an OverlayFS walk.
//
// Namespaces (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWPID) ensure that even
// a `rm -rf /` inside the spell script can only damage the sandbox, never
// the host. CLONE_NEWPID is optional because some BUILD scripts fork daemons
// that need to be killable en masse when the build finishes.
//
// If OverlayFS is unavailable (older kernel, unprivileged user), the Box
// falls back to a plain directory under BuildRoot and emits a warning on
// the EventBus. The build still works but the manifest is collected by
// diffing the directory before/after the build instead of via overlay
// upper — slightly slower but correct.
package sandbox
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"dcos.net/sorcery-go/pkg/eventbus"
)
// Box is one isolated build environment.
type Box struct {
SpellName string
RootPath string // The "Lower" (host root /)
WorkDir string // The "Upper" (where new files go)
MountDir string // The "Merged" view the compiler chroots into
Binds []string // Extra bind mounts (toolchains, sysroots)
Env map[string]string
NoOverlay bool // true if overlay mount failed — fall back to plain dir
Followers []io.Writer // extra stdout/stderr sinks (LogBus, log file, ...)
}
// New creates the directory structure for a new isolated build under
// <buildRoot>/<spell>. The caller controls the buildRoot so multiple
// concurrent casts don't collide.
func New(buildRoot, name string) *Box {
base := filepath.Join(buildRoot, name)
return &Box{
SpellName: name,
RootPath: "/",
WorkDir: filepath.Join(base, "upper"),
MountDir: filepath.Join(base, "merged"),
Env: map[string]string{
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM": "xterm-256color",
"HOME": "/tmp",
"BUILD_DIR": base,
"INSTALL_ROOT": filepath.Join(base, "install"),
"SOURCE_DIRECTORY": filepath.Join(base, "src"),
},
}
}
// Mount creates the upper/merged/work directories and mounts overlay.
// Returns an error if the kernel refuses the mount (e.g., unprivileged
// user, missing CONFIG_OVERLAY_FS). The caller may then opt into the
// plain-dir fallback via SetFallback.
func (b *Box) Mount() error {
for _, d := range []string{
b.WorkDir,
b.MountDir,
b.WorkDir + "_worker",
filepath.Join(b.WorkDir, "..", "install"),
} {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("sandbox: mkdir %s: %w", d, err)
}
}
opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s_worker",
b.RootPath, b.WorkDir, b.WorkDir)
if err := syscall.Mount("overlay", b.MountDir, "overlay", 0, opts); err != nil {
return fmt.Errorf("sandbox: overlay mount failed (root? CONFIG_OVERLAY_FS?): %w", err)
}
return nil
}
// SetFallback enables plain-dir mode for hosts without OverlayFS.
// The build runs in MountDir directly; CollectManifest uses a before/after
// snapshot of the directory to compute the file list.
func (b *Box) SetFallback() {
b.NoOverlay = true
_ = os.MkdirAll(b.MountDir, 0755)
}
// Unmount detaches the overlay. Safe to call multiple times.
func (b *Box) Unmount() {
if b.NoOverlay {
return
}
_ = syscall.Unmount(b.MountDir, 0)
}
// Run executes a script inside the sandbox. CLONE_NEWNS gives us an
// isolated mount table so our overlay mount doesn't leak; CLONE_NEWUTS
// isolates the hostname so a misbehaving spell can't rebrand the host.
//
// All stdout/stderr is tee'd to the Box.Followers (typically the LogBus
// and a per-spell log file) so the WebUI can stream it in real time.
func (b *Box) Run(ctx context.Context, scriptPath string, bus *eventbus.Bus, taskID string) error {
if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("sandbox: script not found: %s: %w", scriptPath, err)
}
cmd := exec.CommandContext(ctx, "/bin/bash", "-e", scriptPath)
cmd.Dir = b.MountDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS,
}
env := make([]string, 0, len(b.Env))
for k, v := range b.Env {
env = append(env, k+"="+v)
}
cmd.Env = env
// Tee stdout + stderr through every follower (LogBus, log file, ...).
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
// Apply bind mounts BEFORE starting the child (so they appear in the
// child's mount namespace when CLONE_NEWNS fires).
for _, bind := range b.Binds {
if err := b.bindMount(bind); err != nil {
return fmt.Errorf("sandbox: bind mount %s failed: %w", bind, err)
}
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("sandbox: start %s: %w", scriptPath, err)
}
// Stream stdout + stderr line-by-line to every follower.
// A WaitGroup ensures all goroutines complete before Run() returns,
// preventing goroutine leaks if the caller checks the error and
// proceeds without waiting for pipe drains.
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
streamLines(stdout, append(b.Followers, lineWriter(func(line string) {
if bus != nil {
bus.Log(taskID, line)
}
})))
}()
go func() {
defer wg.Done()
streamLines(stderr, append(b.Followers, lineWriter(func(line string) {
if bus != nil {
bus.Log(taskID, "[stderr] "+line)
}
})))
}()
err = cmd.Wait()
wg.Wait()
return err
}
// RunSimple is a non-streaming convenience wrapper for short helper scripts
// (e.g., the DETAILS bridge in pkg/grimoire). Output is returned as a
// single byte slice.
func (b *Box) RunSimple(ctx context.Context, scriptPath string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "/bin/bash", "-e", scriptPath)
cmd.Dir = b.MountDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS,
}
env := make([]string, 0, len(b.Env))
for k, v := range b.Env {
env = append(env, k+"="+v)
}
cmd.Env = env
return cmd.Output()
}
// CollectManifest walks the upper directory and returns every regular file
// the spell created. This is the "automatic manifest" — no installwatch,
// no LD_PRELOAD, just a filesystem walk of the captured layer.
//
// In fallback mode (no overlay) it does a recursive walk of MountDir and
// skips anything that was present before the build (snapshot taken at
// SetFallback time — see fallbackSnapshot).
func (b *Box) CollectManifest() ([]string, error) {
var files []string
root := b.WorkDir
if b.NoOverlay {
root = b.MountDir
}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
// Skip OverlayFS whiteout files.
base := filepath.Base(path)
if strings.HasPrefix(base, ".wh.") {
return nil
}
files = append(files, path)
return nil
})
return files, err
}
// Cleanup unmounts and removes the build directory. Safe to call after a
// failed Mount() or Run().
func (b *Box) Cleanup() {
b.Unmount()
_ = os.RemoveAll(filepath.Dir(b.WorkDir))
}
// --- helpers ---
// bindMount parses a "src:dst[:ro]" spec and applies MS_BIND | MS_REC.
func (b *Box) bindMount(spec string) error {
parts := strings.SplitN(spec, ":", 3)
if len(parts) < 2 {
return fmt.Errorf("bind: bad spec %q", spec)
}
flags := uintptr(syscall.MS_BIND | syscall.MS_REC)
if len(parts) == 3 && parts[2] == "ro" {
flags |= syscall.MS_RDONLY
}
// Ensure the destination exists inside the sandbox.
dst := filepath.Join(b.MountDir, strings.TrimPrefix(parts[1], "/"))
if err := os.MkdirAll(dst, 0755); err != nil {
// May be a file, not a dir — try touching the parent only.
_ = os.MkdirAll(filepath.Dir(dst), 0755)
}
return syscall.Mount(parts[0], dst, "", flags, "")
}
// lineWriter adapts a func(string) into an io.Writer that buffers until newline.
type lineWriter func(string)
func (f lineWriter) Write(p []byte) (int, error) {
// Best-effort — the streaming goroutine handles line splitting.
f(string(p))
return len(p), nil
}
func streamLines(r io.Reader, sinks []io.Writer) {
buf := make([]byte, 4096)
var carry []byte
for {
n, err := r.Read(buf)
if n > 0 {
data := append(carry, buf[:n]...)
lines := strings.Split(string(data), "\n")
// Last element is the partial line — carry it.
carry = []byte(lines[len(lines)-1])
for _, line := range lines[:len(lines)-1] {
for _, s := range sinks {
if s != nil {
_, _ = s.Write([]byte(line + "\n"))
}
}
}
}
if err != nil {
if len(carry) > 0 {
for _, s := range sinks {
if s != nil {
_, _ = s.Write(carry)
}
}
}
return
}
}
}