aboutsummaryrefslogtreecommitdiffhomepage
path: root/container
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2025-12-08 06:35:36 +0900
committerOphestra <cat@gensokyo.uk>2025-12-08 07:21:04 +0900
commit357cfcddeea14d6babacf7ae8f77e0bad7b24ee5 (patch)
treeeaad515e98541dd3e3f6d0bdfc5f54d360463f48 /container
parent6bf245cf1b8d5e30c051bb6295533d287cb4eda6 (diff)
container: start daemons within container
This is useful for daemons internal to the container. The only current use case is pipewire-pulse. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'container')
-rw-r--r--container/dispatcher.go3
-rw-r--r--container/dispatcher_test.go18
-rw-r--r--container/initdaemon.go105
-rw-r--r--container/initdaemon_test.go102
4 files changed, 227 insertions, 1 deletions
diff --git a/container/dispatcher.go b/container/dispatcher.go
index e1dae6eb..e58346e8 100644
--- a/container/dispatcher.go
+++ b/container/dispatcher.go
@@ -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) }
diff --git a/container/dispatcher_test.go b/container/dispatcher_test.go
index ee31f91e..bbcb26ad 100644
--- a/container/dispatcher_test.go
+++ b/container/dispatcher_test.go
@@ -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()
diff --git a/container/initdaemon.go b/container/initdaemon.go
new file mode 100644
index 00000000..ee360dc9
--- /dev/null
+++ b/container/initdaemon.go
@@ -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) }
diff --git a/container/initdaemon_test.go b/container/initdaemon_test.go
new file mode 100644
index 00000000..5ab834db
--- /dev/null
+++ b/container/initdaemon_test.go
@@ -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"`},
+ })
+}