297 lines
11 KiB
Go
Executable File
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
|
|
}
|
|
}
|
|
}
|