container: start daemons within container
All checks were successful
Test / Create distribution (push) Successful in 36s
Test / Sandbox (push) Successful in 2m32s
Test / Sandbox (race detector) (push) Successful in 4m41s
Test / Hakurei (push) Successful in 5m7s
Test / Hpkg (push) Successful in 5m5s
Test / Hakurei (race detector) (push) Successful in 6m26s
Test / Flake checks (push) Successful in 1m27s

This is useful for daemons internal to the container. The only current use case is pipewire-pulse.

Signed-off-by: Ophestra <cat@gensokyo.uk>
This commit is contained in:
Ophestra 2025-12-08 06:35:36 +09:00
parent 6bf245cf1b
commit 357cfcddee
Signed by: cat
SSH Key Fingerprint: SHA256:gQ67O0enBZ7UdZypgtspB2FDM1g3GVw8nX0XSdcFw8Q
4 changed files with 227 additions and 1 deletions

View File

@ -68,6 +68,8 @@ type syscallDispatcher interface {
notify(c chan<- os.Signal, sig ...os.Signal)
// start starts [os/exec.Cmd].
start(c *exec.Cmd) error
// wait waits on [os/exec.Cmd].
wait(c *exec.Cmd) error
// signal signals the underlying process of [os/exec.Cmd].
signal(c *exec.Cmd, sig os.Signal) error
// evalSymlinks provides [filepath.EvalSymlinks].
@ -170,6 +172,7 @@ func (direct) seccompLoad(rules []std.NativeRule, flags seccomp.ExportFlag) erro
}
func (direct) notify(c chan<- os.Signal, sig ...os.Signal) { signal.Notify(c, sig...) }
func (direct) start(c *exec.Cmd) error { return c.Start() }
func (direct) wait(c *exec.Cmd) error { return c.Wait() }
func (direct) signal(c *exec.Cmd, sig os.Signal) error { return c.Process.Signal(sig) }
func (direct) evalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) }

View File

@ -493,6 +493,21 @@ func (k *kstub) start(c *exec.Cmd) error {
return err
}
func (k *kstub) wait(c *exec.Cmd) error {
k.Helper()
expect := k.Expects("wait")
err := expect.Error(
stub.CheckArg(k.Stub, "c.Path", c.Path, 0),
stub.CheckArgReflect(k.Stub, "c.Args", c.Args, 1),
stub.CheckArgReflect(k.Stub, "c.Env", c.Env, 2),
stub.CheckArg(k.Stub, "c.Dir", c.Dir, 3))
if mgc, ok := expect.Ret.(uintptr); ok && mgc == stub.PanicExit {
panic(stub.PanicExit)
}
return err
}
func (k *kstub) signal(c *exec.Cmd, sig os.Signal) error {
k.Helper()
expect := k.Expects("signal")
@ -759,7 +774,8 @@ func (k *kstub) checkMsg(msg message.Msg) {
}
func (k *kstub) GetLogger() *log.Logger { panic("unreachable") }
func (k *kstub) IsVerbose() bool { panic("unreachable") }
func (k *kstub) IsVerbose() bool { k.Helper(); return k.Expects("isVerbose").Ret.(bool) }
func (k *kstub) SwapVerbose(verbose bool) bool {
k.Helper()

105
container/initdaemon.go Normal file
View File

@ -0,0 +1,105 @@
package container
import (
"context"
"encoding/gob"
"errors"
"fmt"
"os"
"os/exec"
"slices"
"sync/atomic"
"syscall"
"time"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
)
func init() { gob.Register(new(DaemonOp)) }
const (
// daemonTimeout is the duration a [DaemonOp] is allowed to block before the
// [DaemonOp.Target] marker becomes available.
daemonTimeout = 5 * time.Second
)
// Daemon appends an [Op] that starts a daemon in the container and blocks until
// [DaemonOp.Target] appears.
func (f *Ops) Daemon(target, path *check.Absolute, args ...string) *Ops {
*f = append(*f, &DaemonOp{target, path, args})
return f
}
// DaemonOp starts a daemon in the container and blocks until Target appears.
type DaemonOp struct {
// Pathname indicating readiness of daemon.
Target *check.Absolute
// Absolute pathname passed to [exec.Cmd].
Path *check.Absolute
// Arguments (excl. first) passed to [exec.Cmd].
Args []string
}
func (d *DaemonOp) Valid() bool { return d != nil && d.Target != nil && d.Path != nil }
func (d *DaemonOp) early(*setupState, syscallDispatcher) error { return nil }
func (d *DaemonOp) apply(*setupState, syscallDispatcher) error { return nil }
func (d *DaemonOp) late(state *setupState, k syscallDispatcher) error {
cmd := exec.CommandContext(state.Context, d.Path.String(), d.Args...)
cmd.Env = state.Env
cmd.Dir = fhs.Root
if state.IsVerbose() {
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
}
// WaitDelay: left unset because lifetime is bound by AdoptWaitDelay on cancellation
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
state.Verbosef("starting %s", d.String())
if err := k.start(cmd); err != nil {
return err
}
var done atomic.Pointer[error]
k.new(func(k syscallDispatcher) {
err := k.wait(cmd)
done.Store(&err)
if err != nil {
state.Verbosef("%s %v", d.String(), err)
}
})
deadline := time.Now().Add(daemonTimeout)
for {
if _, err := k.stat(d.Target.String()); err != nil {
if !errors.Is(err, os.ErrNotExist) {
_ = k.signal(cmd, os.Kill)
return err
}
if time.Now().After(deadline) {
_ = k.signal(cmd, os.Kill)
return context.DeadlineExceeded
}
if errP := done.Load(); errP != nil {
return *errP
}
time.Sleep(500 * time.Microsecond)
continue
}
state.Verbosef("daemon process %d ready", cmd.Process.Pid)
return nil
}
}
func (d *DaemonOp) Is(op Op) bool {
vd, ok := op.(*DaemonOp)
return ok && d.Valid() && vd.Valid() &&
d.Target.Is(vd.Target) && d.Path.Is(vd.Path) &&
slices.Equal(d.Args, vd.Args)
}
func (*DaemonOp) prefix() (string, bool) { return zeroString, false }
func (d *DaemonOp) String() string { return fmt.Sprintf("daemon providing %q", d.Target) }

View File

@ -0,0 +1,102 @@
package container
import (
"os"
"testing"
"hakurei.app/container/check"
"hakurei.app/container/stub"
)
func TestDaemonOp(t *testing.T) {
t.Parallel()
checkSimple(t, "DaemonOp.late", []simpleTestCase{
{"success", func(k *kstub) error {
state := setupState{Params: &Params{Env: []string{"\x00"}}, Context: t.Context(), Msg: k}
return (&DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
Args: []string{"-v"},
}).late(&state, k)
}, stub.Expect{Calls: []stub.Call{
call("isVerbose", stub.ExpectArgs{}, true, nil),
call("verbosef", stub.ExpectArgs{"starting %s", []any{`daemon providing "/run/user/1971/pulse/native"`}}, nil, nil),
call("start", stub.ExpectArgs{"/run/current-system/sw/bin/pipewire-pulse", []string{"/run/current-system/sw/bin/pipewire-pulse", "-v"}, []string{"\x00"}, "/"}, &os.Process{Pid: 0xcafe}, nil),
call("New", stub.ExpectArgs{}, nil, nil),
call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), os.ErrNotExist),
call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), os.ErrNotExist),
call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), os.ErrNotExist),
call("stat", stub.ExpectArgs{"/run/user/1971/pulse/native"}, isDirFi(false), nil),
call("verbosef", stub.ExpectArgs{"daemon process %d ready", []any{0xcafe}}, nil, nil),
}, Tracks: []stub.Expect{{Calls: []stub.Call{
call("wait", stub.ExpectArgs{"/run/current-system/sw/bin/pipewire-pulse", []string{"/run/current-system/sw/bin/pipewire-pulse", "-v"}, []string{"\x00"}, "/"}, uintptr(stub.PanicExit), nil),
}}}}, nil},
})
checkOpsValid(t, []opValidTestCase{
{"nil", (*DaemonOp)(nil), false},
{"zero", new(DaemonOp), false},
{"valid", &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
Args: []string{"-v"},
}, true},
})
checkOpsBuilder(t, []opsBuilderTestCase{
{"pipewire-pulse", new(Ops).Daemon(
check.MustAbs("/run/user/1971/pulse/native"),
check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"), "-v",
), Ops{
&DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
Args: []string{"-v"},
},
}},
})
checkOpIs(t, []opIsTestCase{
{"zero", new(DaemonOp), new(DaemonOp), false},
{"args differs", &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
Args: []string{"-v"},
}, &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
}, false},
{"path differs", &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire"),
}, &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
}, false},
{"target differs", &DaemonOp{
Target: check.MustAbs("/run/user/65534/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
}, &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
}, false},
{"equals", &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
}, &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
Path: check.MustAbs("/run/current-system/sw/bin/pipewire-pulse"),
}, true},
})
checkOpMeta(t, []opMetaTestCase{
{"pipewire-pulse", &DaemonOp{
Target: check.MustAbs("/run/user/1971/pulse/native"),
}, zeroString, `daemon providing "/run/user/1971/pulse/native"`},
})
}