diff options
| author | Ophestra <cat@gensokyo.uk> | 2026-08-29 18:58:06 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-08-29 18:58:06 +0900 |
| commit | c2d900ec74e03e9c782cfe7b7ce06ff62cda6601 (patch) | |
| tree | fb13320bfdc62d810aa59769e8809dc2f707919e /pkg/exec.go | |
| parent | bd4f29909e0750a4660eaf48e3169777a265b0ab (diff) | |
Closes #43.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'pkg/exec.go')
| -rw-r--r-- | pkg/exec.go | 808 |
1 files changed, 808 insertions, 0 deletions
diff --git a/pkg/exec.go b/pkg/exec.go new file mode 100644 index 00000000..b40ae84b --- /dev/null +++ b/pkg/exec.go @@ -0,0 +1,808 @@ +package pkg + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strconv" + "sync" + "syscall" + "time" + "unique" + + "hakurei.app/check" + "hakurei.app/container" + "hakurei.app/container/seccomp" + "hakurei.app/container/std" + "hakurei.app/ext" + "hakurei.app/fhs" + "hakurei.app/message" +) + +// AbsWork is the container pathname [TContext.GetWorkDir] is mounted on. +var AbsWork = fhs.AbsRoot.Append("work/") + +const ( + // EnvJobs is the name of the environment variable holding a decimal + // representation of the preferred job count. Its value must not affect cure + // outcome. + EnvJobs = "CURE_JOBS" + // EnvLoad is the name of the environment variable holding a decimal + // representation of the preferred loadavg target. Its value must not affect + // cure outcome. + EnvLoad = "CURE_LOAD" +) + +// ExecPath is a slice of [Artifact] and the [check.Absolute] pathname to make +// it available at under in the container. +type ExecPath struct { + // Pathname in the container mount namespace. + P *check.Absolute + // Artifacts to mount on the pathname, must contain at least one [Artifact]. + // If there are multiple entries or W is true, P is set up as an overlay + // mount, and entries of A must not implement [FileArtifact]. + A []Artifact + // Whether to make the mount point writable via the temp directory. + W bool +} + +// GetArtifactFunc is the function signature of [FContext.GetArtifact]. +type GetArtifactFunc func(Artifact) (*check.Absolute, unique.Handle[Checksum]) + +// PromoteLayers returns artifacts with identical-by-content layers promoted to +// the highest priority instance, as if mounted via [ExecPath]. +func PromoteLayers( + artifacts []Artifact, + getArtifact GetArtifactFunc, + report func(i int, d Artifact), +) []*check.Absolute { + layers := make([]*check.Absolute, 0, len(artifacts)) + checksums := make(map[unique.Handle[Checksum]]struct{}, len(artifacts)) + for i := range artifacts { + d := artifacts[len(artifacts)-1-i] + pathname, checksum := getArtifact(d) + if _, ok := checksums[checksum]; ok { + report(len(artifacts)-1-i, d) + continue + } + checksums[checksum] = struct{}{} + layers = append(layers, pathname) + } + slices.Reverse(layers) + return layers +} + +// layers returns pathnames collected from A deduplicated via [PromoteLayers]. +func (p *ExecPath) layers( + msg message.Msg, + getArtifact GetArtifactFunc, + ident func(a Artifact) unique.Handle[ID], +) []*check.Absolute { + return PromoteLayers(p.A, getArtifact, func(i int, d Artifact) { + if msg.IsVerbose() { + msg.Verbosef("promoted layer %d as %s", i, reportName(d, ident(d))) + } + }) +} + +// Path returns a populated [ExecPath]. +func Path(pathname *check.Absolute, writable bool, a ...Artifact) ExecPath { + return ExecPath{pathname, a, writable} +} + +// MustPath is like [Path], but takes a string pathname via [check.MustAbs]. +func MustPath(pathname string, writable bool, a ...Artifact) ExecPath { + return ExecPath{check.MustAbs(pathname), a, writable} +} + +var ( + binfmt map[string]container.BinfmtEntry + binfmtMu sync.RWMutex +) + +// RegisterArch arranges for [KindExec] and [KindExecNet] to support a new +// architecture via a binfmt_misc entry. Each architecture must be registered +// at most once. +func RegisterArch(arch string, e container.BinfmtEntry) { + if arch == "" { + panic(UnsupportedArchError(arch)) + } + + binfmtMu.Lock() + defer binfmtMu.Unlock() + + if binfmt == nil { + binfmt = make(map[string]container.BinfmtEntry) + } + + if _, ok := binfmt[arch]; ok { + panic("attempting to register " + strconv.Quote(arch) + " twice") + } + binfmt[arch] = e +} + +const ( + // ExecTimeoutDefault replaces out of range [NewExec] timeout values. + ExecTimeoutDefault = 15 * time.Minute + // ExecTimeoutMax is the arbitrary upper bound of [NewExec] timeout. + ExecTimeoutMax = 48 * time.Hour +) + +// An execArtifact is an [Artifact] that produces output by running a program +// part of another [Artifact] in a [container] to produce its output. +// +// Methods of execArtifact does not modify any struct field or underlying arrays +// referred to by slices. +type execArtifact struct { + // Caller-supplied user-facing reporting name, guaranteed to be nonzero + // during initialisation. + name string + // Target architecture. + arch string + // Caller-supplied inner mount points. + paths []ExecPath + + // Passed through to [container.Params]. + dir *check.Absolute + // Passed through to [container.Params]. + env []string + // Passed through to [container.Params]. + path *check.Absolute + // Passed through to [container.Params]. + args []string + + // Duration the initial process is allowed to run. The zero value is + // equivalent to [ExecTimeoutDefault]. + timeout time.Duration + + // Caller-supplied exclusivity value, returned as is by IsExclusive. + exclusive bool +} + +var _ fmt.Stringer = new(execArtifact) + +// execMeasuredArtifact is like execArtifact but implements [KnownChecksum] and +// has its resulting container optionally keep the host net namespace. +type execMeasuredArtifact struct { + checksum Checksum + + // Whether to keep host net namespace. + hostNet bool + + execArtifact +} + +var _ KnownChecksum = new(execMeasuredArtifact) + +// Checksum returns the caller-supplied checksum. +func (a *execMeasuredArtifact) Checksum() Checksum { return a.checksum } + +// Kind returns [KindExecNet], or [KindExec] if hostNet is false. +func (a *execMeasuredArtifact) Kind() Kind { + if a == nil || a.hostNet { + return KindExecNet + } + return KindExec +} + +// Cure cures the [Artifact] in the container described by the caller. The +// container optionally retains host networking. +func (a *execMeasuredArtifact) Cure(f *FContext) error { + return a.cure(f, a.hostNet) +} + +// ErrNetChecksum is panicked by [NewExec] if host net namespace is requested +// with a nil checksum. +var ErrNetChecksum = errors.New("attempting to keep net namespace without checksum") + +// NewExec returns a new [Artifact] that executes the program path in a +// container with specified paths bind mounted read-only in order. A private +// instance of /proc and /dev is made available to the container. +// +// The working and temporary directories are both created and mounted writable +// on [AbsWork] and [fhs.AbsTmp] respectively. If one or more paths target +// [AbsWork], the final entry is set up as a writable overlay mount on /work for +// which the upperdir is the host side work directory. In this configuration, +// the W field is ignored, and the program must avoid causing whiteout files to +// be created. Cure fails if upperdir ends up with entries other than directory, +// regular or symlink. +// +// If checksum is non-nil, the resulting [Artifact] implements [KnownChecksum] +// and its container optionally runs in the host net namespace. +// +// The container is allowed to run for the specified duration before the initial +// process and all processes originating from it is terminated. A zero or +// negative timeout value is equivalent tp [ExecTimeoutDefault], a timeout value +// greater than [ExecTimeoutMax] is equivalent to [ExecTimeoutMax]. +// +// The user-facing name and exclusivity value are not accessible from the +// container and does not affect curing outcome. Because of this, it is omitted +// from parameter data for computing identifier. +func NewExec( + name, arch string, + checksum *Checksum, + timeout time.Duration, + hostNet, exclusive bool, + + dir *check.Absolute, + env []string, + pathname *check.Absolute, + args []string, + + paths ...ExecPath, +) Artifact { + if name == "" { + name = "exec-" + filepath.Base(pathname.String()) + } + if arch == "" { + arch = runtime.GOARCH + } + if timeout <= 0 { + timeout = ExecTimeoutDefault + } + if timeout > ExecTimeoutMax { + timeout = ExecTimeoutMax + } + a := execArtifact{name, arch, paths, dir, env, pathname, args, timeout, exclusive} + if checksum == nil { + if hostNet { + panic(ErrNetChecksum) + } + return &a + } + return &execMeasuredArtifact{*checksum, hostNet, a} +} + +// Kind returns the hardcoded [Kind] constant. +func (*execArtifact) Kind() Kind { return KindExec } + +// Params writes paths, executable pathname and args. +func (a *execArtifact) Params(ctx *IContext) { + ctx.WriteString(a.arch) + ctx.WriteString(a.name) + + ctx.WriteUint32(uint32(len(a.paths))) + for _, p := range a.paths { + if p.P != nil { + ctx.WriteString(p.P.String()) + } else { + ctx.WriteString("invalid P\x00") + } + + ctx.WriteUint32(uint32(len(p.A))) + for _, d := range p.A { + ctx.WriteIdent(d) + } + + if p.W { + ctx.WriteUint32(1) + } else { + ctx.WriteUint32(0) + } + } + + ctx.WriteString(a.dir.String()) + + ctx.WriteUint32(uint32(len(a.env))) + for _, e := range a.env { + ctx.WriteString(e) + } + + ctx.WriteString(a.path.String()) + + ctx.WriteUint32(uint32(len(a.args))) + for _, arg := range a.args { + ctx.WriteString(arg) + } + + ctx.WriteUint32(uint32(a.timeout & 0xffffffff)) + ctx.WriteUint32(uint32(a.timeout >> 32)) + + if a.exclusive { + ctx.WriteUint32(1) + } else { + ctx.WriteUint32(0) + } +} + +// UnsupportedArchError describes an unsupported or invalid architecture. +type UnsupportedArchError string + +func (e UnsupportedArchError) Error() string { + if e == "" { + return "invalid architecture name" + } + return "unsupported architecture " + string(e) +} + +// readExecArtifact interprets IR values and returns the address of execArtifact +// or execNetArtifact. +func readExecArtifact(r *IRReader, net bool) Artifact { + r.DiscardAll() + + arch := r.ReadString() + if arch == "" { + panic(UnsupportedArchError(arch)) + } + + name := r.ReadString() + + sz := r.ReadUint32() + if sz > irMaxDeps { + panic(ErrIRDepend) + } + paths := make([]ExecPath, sz) + for i := range paths { + paths[i].P = check.MustAbs(r.ReadString()) + + sz = r.ReadUint32() + if sz > irMaxDeps { + panic(ErrIRDepend) + } + paths[i].A = make([]Artifact, sz) + for j := range paths[i].A { + paths[i].A[j] = r.ReadIdent() + } + + paths[i].W = r.ReadUint32() != 0 + } + + dir := check.MustAbs(r.ReadString()) + + sz = r.ReadUint32() + if sz > irMaxValues { + panic(ErrIRValues) + } + env := make([]string, sz) + for i := range env { + env[i] = r.ReadString() + } + + pathname := check.MustAbs(r.ReadString()) + + sz = r.ReadUint32() + if sz > irMaxValues { + panic(ErrIRValues) + } + args := make([]string, sz) + for i := range args { + args[i] = r.ReadString() + } + + timeout := time.Duration(r.ReadUint32()) + timeout |= time.Duration(r.ReadUint32()) << 32 + + exclusive := r.ReadUint32() != 0 + + checksum, ok := r.Finalise() + var checksumP *Checksum + if ok { + checksumP = new(checksum.Value()) + } + + if net && !ok { + panic(ErrExpectedChecksum) + } + + return NewExec( + name, arch, checksumP, timeout, net, exclusive, dir, env, pathname, args, paths..., + ) +} + +func init() { + register(KindExec, + func(r *IRReader) Artifact { return readExecArtifact(r, false) }) + register(KindExecNet, + func(r *IRReader) Artifact { return readExecArtifact(r, true) }) +} + +// Inputs returns a slice of all artifacts collected from caller-supplied +// [ExecPath]. +func (a *execArtifact) Inputs() []Artifact { + artifacts := make([][]Artifact, 0, len(a.paths)) + for _, p := range a.paths { + artifacts = append(artifacts, p.A) + } + return slices.Concat(artifacts...) +} + +// IsExclusive returns the caller-supplied exclusivity value. +func (a *execArtifact) IsExclusive() bool { return a.exclusive } + +// String returns the caller-supplied reporting name. +func (a *execArtifact) String() string { return a.name } + +// Cure cures the [Artifact] in the container described by the caller. +func (a *execArtifact) Cure(f *FContext) (err error) { + return a.cure(f, false) +} + +const ( + // execWaitDelay is passed through to [container.Params]. + execWaitDelay = time.Nanosecond +) + +// scanLinesCR is like [bufio.ScanLines], but also treats a bare \r as an +// end-of-line marker. +func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + ri, ni := bytes.IndexByte(data, '\r'), bytes.IndexByte(data, '\n') + + if ri >= 0 && (ni < 0 || ri < ni) { + if ri+1 == ni { + // We have a full \r\n-terminated line. + return ri + 2, data[:ri], nil + } + // We have a bare \r, probably some kind of progress indicator. + return ri + 1, data[:ri], nil + } + if ni >= 0 && (ri < 0 || ni < ri) { + // We have a full newline-terminated line. + return ni + 1, data[:ni], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +// scanVerbose prefixes program output for a verbose [message.Msg]. +func scanVerbose( + msg message.Msg, + cancel context.CancelFunc, + done chan<- struct{}, + prefix, suffix string, + r io.Reader, +) { + defer close(done) + s := bufio.NewScanner(r) + s.Split(scanLinesCR) + s.Buffer( + make([]byte, bufio.MaxScanTokenSize), + bufio.MaxScanTokenSize<<12, + ) + for s.Scan() { + msg.Verbose(prefix, s.Text()+suffix) + } + if err := s.Err(); err != nil && !errors.Is(err, os.ErrClosed) { + cancel() + msg.Verbose("*"+prefix, err.Error()+suffix) + } +} + +var ( + // ErrInvalidPaths is returned for an [Artifact] of [KindExec] or + // [KindExecNet] specified with invalid paths. + ErrInvalidPaths = errors.New("invalid mount point") +) + +// SeccompPresets is the [seccomp] presets used by exec artifacts. +const SeccompPresets = std.PresetStrict & + ^(std.PresetDenyNS | std.PresetDenyDevel) + +// makeContainer sets up the specified temp and work directories and returns the +// corresponding [container.Container] that would have run for cure. +func (a *execArtifact) makeContainer( + ctx context.Context, + msg message.Msg, + flags, jobs, load int, + hostNet bool, + temp, work *check.Absolute, + getArtifact GetArtifactFunc, + ident func(a Artifact) unique.Handle[ID], +) (z *container.Container, err error) { + overlayWorkIndex := -1 + for i, p := range a.paths { + if p.P == nil || len(p.A) == 0 { + return nil, ErrInvalidPaths + } + if p.P.Is(AbsWork) { + overlayWorkIndex = i + } + } + + var artifactCount int + for _, p := range a.paths { + artifactCount += len(p.A) + } + + z = container.New(ctx, msg) + z.WaitDelay = execWaitDelay + z.SeccompPresets = SeccompPresets + z.SeccompFlags |= seccomp.AllowMultiarch + z.ParentPerm = 0700 + z.HostNet = hostNet + z.HostAbstract = flags&CHostAbstract != 0 + z.Hostname = "cure" + z.SetScheduler = flags&CSchedIdle != 0 + z.SchedPolicy = ext.SCHED_IDLE + if z.HostNet { + z.Hostname = "cure-net" + } + z.Quiet = flags&CSuppressInit != 0 + z.Uid, z.Gid = (1<<10)-1, (1<<10)-1 + z.Dir, z.Path, z.Args = a.dir, a.path, a.args + z.Env = slices.Concat(a.env, []string{ + EnvJobs + "=" + strconv.Itoa(jobs), + EnvLoad + "=" + strconv.Itoa(load), + }) + z.Grow(len(a.paths) + 4) + + if a.arch != runtime.GOARCH { + binfmtMu.RLock() + e, ok := binfmt[a.arch] + binfmtMu.RUnlock() + if !ok { + return nil, UnsupportedArchError(a.arch) + } + z.Binfmt = []container.BinfmtEntry{e} + z.InitAsRoot = true + } + + for i, b := range a.paths { + if i == overlayWorkIndex { + if err = os.MkdirAll(work.String(), 0700); err != nil { + return + } + tempWork := temp.Append(".work") + if err = os.MkdirAll(tempWork.String(), 0700); err != nil { + return + } + z.Overlay( + AbsWork, + work, + tempWork, + b.layers(msg, getArtifact, ident)..., + ) + continue + } + + if a.paths[i].W { + tempUpper, tempWork := temp.Append( + ".upper", strconv.Itoa(i), + ), temp.Append( + ".work", strconv.Itoa(i), + ) + if err = os.MkdirAll(tempUpper.String(), 0700); err != nil { + return + } + if err = os.MkdirAll(tempWork.String(), 0700); err != nil { + return + } + z.Overlay(b.P, tempUpper, tempWork, b.layers(msg, getArtifact, ident)...) + } else if len(b.A) == 1 { + pathname, _ := getArtifact(b.A[0]) + z.Bind(pathname, b.P, 0) + } else { + z.OverlayReadonly(b.P, b.layers(msg, getArtifact, ident)...) + } + } + if overlayWorkIndex < 0 { + z.Bind( + work, + AbsWork, + std.BindWritable|std.BindEnsure, + ) + } + z.Bind( + temp, + fhs.AbsTmp, + std.BindWritable|std.BindEnsure, + ) + z.Proc(fhs.AbsProc).Dev(fhs.AbsDev, true) + return +} + +var ( + // ErrExecBusy is returned entering [Cache.EnterExec] while another + // goroutine has not yet returned from it. + ErrExecBusy = errors.New("scratch directories in use") + // ErrNotExec is returned for unsupported implementations of [Artifact] + // passed to [Cache.EnterExec]. + ErrNotExec = errors.New("attempting to run a non-exec artifact") +) + +// EnterExec runs the container of an [Artifact] of [KindExec] or [KindExecNet] +// with its entry point, argument, and standard streams replaced with values +// supplied by the caller. +func (c *Cache) EnterExec( + ctx context.Context, + a Artifact, + hostname string, + retainSession bool, + stdin io.Reader, + stdout, stderr io.Writer, + path *check.Absolute, + args ...string, +) (err error) { + if !c.inExec.CompareAndSwap(false, true) { + return ErrExecBusy + } + defer c.inExec.Store(false) + + var hostNet bool + var e *execArtifact + switch f := a.(type) { + case *execArtifact: + e = f + + case *execMeasuredArtifact: + e = &f.execArtifact + hostNet = f.hostNet + + default: + return ErrNotExec + } + + deps := Collect(a.Inputs()) + if _, _, err = c.Cure(&deps); err == nil { + return errors.New("unreachable") + } else if !IsCollected(err) { + return + } + + dm := make(map[Artifact]cureRes) + for i, p := range deps { + var res cureRes + res.pathname, res.checksum, err = c.Cure(p) + if err != nil { + return + } + dm[deps[i]] = res + } + + scratch := c.base.Append(dirExecScratch) + temp, work := scratch.Append("temp"), scratch.Append("work") + // work created during makeContainer + if err = os.MkdirAll(temp.String(), 0700); err != nil { + return + } + defer func() { + if chmodErr, removeErr := removeAll(scratch); chmodErr != nil || removeErr != nil { + err = errors.Join(err, chmodErr, removeErr) + } + }() + + var z *container.Container + z, err = e.makeContainer( + ctx, c.msg, + c.attr.Flags, + c.attr.Jobs, + c.attr.Load, + hostNet, + temp, work, + func(a Artifact) (*check.Absolute, unique.Handle[Checksum]) { + if res, ok := dm[a]; ok { + return res.pathname, res.checksum + } + panic(InvalidLookupError(c.Ident(a).Value())) + }, + c.Ident, + ) + if err != nil { + return + } + z.Stdin, z.Stdout, z.Stderr = stdin, stdout, stderr + z.Path, z.Args = path, args + z.RetainSession = retainSession + if stdin == os.Stdin { + if s, ok := os.LookupEnv("TERM"); ok { + z.Env = append(z.Env, "TERM="+s) + } + } + if hostname != "" { + z.Hostname = hostname + } + + if err = z.Start(); err != nil { + return + } + if err = z.Serve(); err != nil { + return + } + return z.Wait() +} + +// cure is like Cure but allows optional host net namespace. +func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) { + ctx, cancel := context.WithTimeout(f.Unwrap(), a.timeout) + defer cancel() + + msg := f.GetMessage() + var z *container.Container + if z, err = a.makeContainer( + ctx, msg, f.cache.attr.Flags, f.GetJobs(), f.GetLoad(), hostNet, + f.GetTempDir(), f.GetWorkDir(), + f.GetArtifact, + f.cache.Ident, + ); err != nil { + return + } + + var status io.Writer + if status, err = f.GetStatusWriter(); err != nil { + return + } + + if msg.IsVerbose() { + var stdout, stderr io.ReadCloser + if stdout, err = z.StdoutPipe(); err != nil { + return + } + if stderr, err = z.StderrPipe(); err != nil { + _ = stdout.Close() + return + } + + brStdout, brStderr := f.cache.getReader(stdout), f.cache.getReader(stderr) + stdoutDone, stderrDone := make(chan struct{}), make(chan struct{}) + + var suffix, prefixO, prefixE string + if f.cache.attr.Flags&CColourOutput != 0 { + suffix = "\x1b[0m" + prefixO = "\x1b[1;37m(" + a.name + ")\x1b[0m" + prefixE = "\x1b[1;97m(" + a.name + ")\x1b[0m" + } else { + prefixO = "(" + a.name + ":1)" + prefixE = "(" + a.name + ":2)" + } + + go scanVerbose( + msg, cancel, stdoutDone, + prefixO, suffix, + io.TeeReader(brStdout, status), + ) + go scanVerbose( + msg, cancel, stderrDone, + prefixE, suffix, + io.TeeReader(brStderr, status), + ) + defer func() { + if err != nil && !errors.As(err, new(*exec.ExitError)) { + _ = stdout.Close() + _ = stderr.Close() + } + + <-stdoutDone + <-stderrDone + f.cache.putReader(brStdout) + f.cache.putReader(brStderr) + }() + } else { + z.Stdout, z.Stderr = status, status + } + + if err = z.Start(); err != nil { + return + } + if err = z.Serve(); err != nil { + return + } + if err = z.Wait(); err != nil { + return + } + + // do not allow empty directories to succeed + for { + err = syscall.Rmdir(f.GetWorkDir().String()) + if err != syscall.EINTR { + break + } + } + if err != nil && errors.Is(err, syscall.ENOTEMPTY) { + err = nil + } + return +} |
