All checks were successful
Test / Create distribution (push) Successful in 1m0s
Test / Sandbox (push) Successful in 2m41s
Test / Hakurei (push) Successful in 4m1s
Test / ShareFS (push) Successful in 4m1s
Test / Hpkg (push) Successful in 4m35s
Test / Sandbox (race detector) (push) Successful in 5m4s
Test / Hakurei (race detector) (push) Successful in 6m0s
Test / Flake checks (push) Successful in 1m46s
This should hopefully provide good separation between the artifact curing backend implementation and the (still work in progress) language. Making the IR parseable also guarantees uniqueness of the representation. Signed-off-by: Ophestra <cat@gensokyo.uk>
500 lines
12 KiB
Go
500 lines
12 KiB
Go
package pkg
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"slices"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
"unique"
|
|
|
|
"hakurei.app/container"
|
|
"hakurei.app/container/check"
|
|
"hakurei.app/container/fhs"
|
|
"hakurei.app/container/seccomp"
|
|
"hakurei.app/container/std"
|
|
"hakurei.app/message"
|
|
)
|
|
|
|
// AbsWork is the container pathname [CureContext.GetWorkDir] is mounted on.
|
|
var AbsWork = fhs.AbsRoot.Append("work/")
|
|
|
|
// 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
|
|
}
|
|
|
|
// layers returns pathnames collected from A deduplicated by checksum.
|
|
func (p *ExecPath) layers(f *FContext) []*check.Absolute {
|
|
msg := f.GetMessage()
|
|
|
|
layers := make([]*check.Absolute, 0, len(p.A))
|
|
checksums := make(map[unique.Handle[Checksum]]struct{}, len(p.A))
|
|
for i := range p.A {
|
|
d := p.A[len(p.A)-1-i]
|
|
pathname, checksum := f.GetArtifact(d)
|
|
if _, ok := checksums[checksum]; ok {
|
|
if msg.IsVerbose() {
|
|
msg.Verbosef(
|
|
"promoted layer %d as %s",
|
|
len(p.A)-1-i, reportName(d, f.cache.Ident(d)),
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
checksums[checksum] = struct{}{}
|
|
layers = append(layers, pathname)
|
|
}
|
|
slices.Reverse(layers)
|
|
return layers
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
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
|
|
// 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)
|
|
|
|
// execNetArtifact is like execArtifact but implements [KnownChecksum] and has
|
|
// its resulting container keep the host net namespace.
|
|
type execNetArtifact struct {
|
|
checksum Checksum
|
|
|
|
execArtifact
|
|
}
|
|
|
|
var _ KnownChecksum = new(execNetArtifact)
|
|
|
|
// Checksum returns the caller-supplied checksum.
|
|
func (a *execNetArtifact) Checksum() Checksum { return a.checksum }
|
|
|
|
// Kind returns the hardcoded [Kind] constant.
|
|
func (*execNetArtifact) Kind() Kind { return KindExecNet }
|
|
|
|
// Cure cures the [Artifact] in the container described by the caller. The
|
|
// container retains host networking.
|
|
func (a *execNetArtifact) Cure(f *FContext) error {
|
|
return a.cure(f, true)
|
|
}
|
|
|
|
// 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 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 string,
|
|
checksum *Checksum,
|
|
timeout time.Duration,
|
|
exclusive bool,
|
|
|
|
dir *check.Absolute,
|
|
env []string,
|
|
pathname *check.Absolute,
|
|
args []string,
|
|
|
|
paths ...ExecPath,
|
|
) Artifact {
|
|
if name == "" {
|
|
name = "exec-" + path.Base(pathname.String())
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = ExecTimeoutDefault
|
|
}
|
|
if timeout > ExecTimeoutMax {
|
|
timeout = ExecTimeoutMax
|
|
}
|
|
a := execArtifact{name, paths, dir, env, pathname, args, timeout, exclusive}
|
|
if checksum == nil {
|
|
return &a
|
|
}
|
|
return &execNetArtifact{*checksum, 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.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)
|
|
}
|
|
}
|
|
|
|
// readExecArtifact interprets IR values and returns the address of execArtifact
|
|
// or execNetArtifact.
|
|
func readExecArtifact(r *IRReader, net bool) Artifact {
|
|
r.DiscardAll()
|
|
|
|
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 net {
|
|
if !ok {
|
|
panic(ErrExpectedChecksum)
|
|
}
|
|
checksumVal := checksum.Value()
|
|
checksumP = &checksumVal
|
|
} else {
|
|
if ok {
|
|
panic(ErrUnexpectedChecksum)
|
|
}
|
|
}
|
|
|
|
return NewExec(
|
|
name, checksumP, timeout, 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) })
|
|
}
|
|
|
|
// Dependencies returns a slice of all artifacts collected from caller-supplied
|
|
// [ExecPath].
|
|
func (a *execArtifact) Dependencies() []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
|
|
)
|
|
|
|
// scanVerbose prefixes program output for a verbose [message.Msg].
|
|
func scanVerbose(
|
|
msg message.Msg,
|
|
done chan<- struct{},
|
|
prefix string,
|
|
r io.Reader,
|
|
) {
|
|
defer close(done)
|
|
s := bufio.NewScanner(r)
|
|
s.Buffer(
|
|
make([]byte, bufio.MaxScanTokenSize),
|
|
bufio.MaxScanTokenSize<<12,
|
|
)
|
|
for s.Scan() {
|
|
msg.Verbose(prefix, s.Text())
|
|
}
|
|
if err := s.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
|
|
msg.Verbose("*"+prefix, err)
|
|
}
|
|
}
|
|
|
|
// cure is like Cure but allows optional host net namespace. This is used for
|
|
// the [KnownChecksum] variant where networking is allowed.
|
|
func (a *execArtifact) cure(f *FContext, hostNet bool) (err error) {
|
|
overlayWorkIndex := -1
|
|
for i, p := range a.paths {
|
|
if p.P == nil || len(p.A) == 0 {
|
|
return os.ErrInvalid
|
|
}
|
|
if p.P.Is(AbsWork) {
|
|
overlayWorkIndex = i
|
|
}
|
|
}
|
|
|
|
var artifactCount int
|
|
for _, p := range a.paths {
|
|
artifactCount += len(p.A)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(f.Unwrap(), a.timeout)
|
|
defer cancel()
|
|
|
|
z := container.New(ctx, f.GetMessage())
|
|
z.WaitDelay = execWaitDelay
|
|
z.SeccompPresets |= std.PresetStrict & ^std.PresetDenyNS
|
|
z.SeccompFlags |= seccomp.AllowMultiarch
|
|
z.ParentPerm = 0700
|
|
z.HostNet = hostNet
|
|
z.Hostname = "cure"
|
|
if z.HostNet {
|
|
z.Hostname = "cure-net"
|
|
}
|
|
z.Uid, z.Gid = (1<<10)-1, (1<<10)-1
|
|
if msg := f.GetMessage(); 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
|
|
}
|
|
defer func() {
|
|
if err != nil && !errors.As(err, new(*exec.ExitError)) {
|
|
_ = stdout.Close()
|
|
_ = stderr.Close()
|
|
}
|
|
}()
|
|
|
|
stdoutDone, stderrDone := make(chan struct{}), make(chan struct{})
|
|
go scanVerbose(msg, stdoutDone, "("+a.name+":1)", stdout)
|
|
go scanVerbose(msg, stderrDone, "("+a.name+":2)", stderr)
|
|
defer func() { <-stdoutDone; <-stderrDone }()
|
|
}
|
|
|
|
z.Dir, z.Env, z.Path, z.Args = a.dir, a.env, a.path, a.args
|
|
z.Grow(len(a.paths) + 4)
|
|
|
|
temp, work := f.GetTempDir(), f.GetWorkDir()
|
|
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(f)...,
|
|
)
|
|
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(f)...)
|
|
} else if len(b.A) == 1 {
|
|
pathname, _ := f.GetArtifact(b.A[0])
|
|
z.Bind(pathname, b.P, 0)
|
|
} else {
|
|
z.OverlayReadonly(b.P, b.layers(f)...)
|
|
}
|
|
}
|
|
if overlayWorkIndex < 0 {
|
|
z.Bind(
|
|
work,
|
|
AbsWork,
|
|
std.BindWritable|std.BindEnsure,
|
|
)
|
|
}
|
|
z.Bind(
|
|
f.GetTempDir(),
|
|
fhs.AbsTmp,
|
|
std.BindWritable|std.BindEnsure,
|
|
)
|
|
z.Proc(fhs.AbsProc).Dev(fhs.AbsDev, true)
|
|
|
|
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(work.String())
|
|
if err != syscall.EINTR {
|
|
break
|
|
}
|
|
}
|
|
if err != nil && errors.Is(err, syscall.ENOTEMPTY) {
|
|
err = nil
|
|
}
|
|
return
|
|
}
|