Files
cthulhu/internal/app/tentacle_management_test.go

284 lines
11 KiB
Go

package app
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/preferences"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func managed_tentacle_fixture(t *testing.T, path string, contained bool) (*tentacleRuntime, *tentacleBindingRecorder) {
t.Helper()
bindings := &tentacleBindingRecorder{}
runtime := newTentacleRuntime(slog.New(slog.DiscardHandler), bindings, "", "auto")
runtime.registry = &tentacle.Registry{Path: path}
runtime.requests = make(chan tentacleManagementCall, 16)
if !contained {
runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil {
t.Error(err)
}
})
return runtime, bindings
}
func manage_tentacle(t *testing.T, runtime *tentacleRuntime, action, id string, entry *tentacle.Entry) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
if err := runtime.Manage(ctx, tentacle.ManagementRequest{Action: action, ID: id, Entry: entry}); err != nil {
t.Fatalf("%s: %v", action, err)
}
}
func probe_entry(t *testing.T, id, mode string) tentacle.Entry {
t.Helper()
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return tentacle.Entry{ID: id, Program: []string{executable, "-test.run=^TestTentacleReaderPeer$", "--", "reader-peer", filepath.Join(t.TempDir(), mode)}}
}
func wait_managed_state(t *testing.T, runtime *tentacleRuntime, id, state string) {
t.Helper()
waitTentacle(t, func() bool {
for _, status := range runtime.Snapshot().Entries {
if status.Entry.ID == id {
return status.State == state
}
}
return false
})
}
func TestManagedTentaclePersistenceLifecycleAndRecovery(t *testing.T) {
path := filepath.Join(t.TempDir(), "tentacles.toml")
runtime, bindings := managed_tentacle_fixture(t, path, false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
first := probe_entry(t, "first", "called")
second := probe_entry(t, "second", "called")
manage_tentacle(t, runtime, "add", first.ID, &first)
manage_tentacle(t, runtime, "add", second.ID, &second)
if len(bindings.TentacleBindings()) != 0 {
t.Fatal("adding disabled entries launched them")
}
manage_tentacle(t, runtime, "enable", first.ID, nil)
wait_managed_state(t, runtime, first.ID, "active")
firstCommand := bindings.TentacleBindings()[0].Command
manage_tentacle(t, runtime, "enable", first.ID, nil)
if bindings.TentacleBindings()[0].Command != firstCommand {
t.Fatal("enable of active entry restarted it")
}
manage_tentacle(t, runtime, "enable", second.ID, nil)
wait_managed_state(t, runtime, second.ID, "active")
manage_tentacle(t, runtime, "disable", first.ID, nil)
wait_managed_state(t, runtime, first.ID, "disabled")
waitTentacle(t, func() bool {
bound := bindings.TentacleBindings()
return len(bound) > 0 && bound[0].Command != firstCommand
})
runtime.Invoke(context.Background(), firstCommand)
if _, err := os.Stat(first.Program[len(first.Program)-1]); !os.IsNotExist(err) {
t.Fatal("stale command invoked disabled child")
}
secondCommand := bindings.TentacleBindings()[0].Command
manage_tentacle(t, runtime, "retry", second.ID, nil)
wait_managed_state(t, runtime, second.ID, "active")
if bindings.TentacleBindings()[0].Command == secondCommand {
t.Fatal("retry reused old session identity")
}
loaded, err := (&tentacle.Registry{Path: path}).Load()
if err != nil || len(loaded) != 2 || loaded[0].Enabled || !loaded[1].Enabled {
t.Fatalf("persisted state: %v %v", loaded, err)
}
runtime.Disable()
if err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "enable", ID: first.ID}); !errors.Is(err, tentacle.ErrDisabled) {
t.Fatalf("emergency bypass: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil {
t.Fatal(err)
}
restarted, _ := managed_tentacle_fixture(t, path, false)
if err := restarted.Start(context.Background()); err != nil {
t.Fatal(err)
}
wait_managed_state(t, restarted, second.ID, "active")
wait_managed_state(t, restarted, first.ID, "disabled")
manage_tentacle(t, restarted, "remove", first.ID, nil)
manage_tentacle(t, restarted, "remove", second.ID, nil)
loaded, err = (&tentacle.Registry{Path: path}).Load()
if err != nil || len(loaded) != 0 {
t.Fatalf("remove all: %v %v", loaded, err)
}
if _, err := os.Stat(second.Program[0]); err != nil {
t.Fatal("removing configuration deleted the executable")
}
}
func TestManagedTentacleFailureRetryAndSaveFailure(t *testing.T) {
path := filepath.Join(t.TempDir(), "tentacles.toml")
runtime, bindings := managed_tentacle_fixture(t, path, false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
entry := probe_entry(t, "probe", "crash")
entry.Enabled = true
manage_tentacle(t, runtime, "add", entry.ID, &entry)
wait_managed_state(t, runtime, entry.ID, "active")
runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
wait_managed_state(t, runtime, entry.ID, "failed")
if !runtime.Snapshot().Entries[0].Entry.Enabled {
t.Fatal("failure changed desired enablement")
}
manage_tentacle(t, runtime, "retry", entry.ID, nil)
wait_managed_state(t, runtime, entry.ID, "active")
entry.Program[len(entry.Program)-1] = filepath.Join(t.TempDir(), "called")
entry.Presentation = true
manage_tentacle(t, runtime, "update", entry.ID, &entry)
wait_managed_state(t, runtime, entry.ID, "active")
if !runtime.Snapshot().Entries[0].Entry.Presentation {
t.Fatal("grant lost")
}
if err := os.WriteFile(path, []byte("version = 1\n"), 0600); err != nil {
t.Fatal(err)
}
if err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "disable", ID: entry.ID}); err == nil {
t.Fatal("overwrote external edits")
}
if runtime.Snapshot().Entries[0].State != "active" {
t.Fatal("failed persistence changed runtime")
}
}
func TestManagedTentacleEmptyAndBrokenRegistryDoNotLaunch(t *testing.T) {
for _, broken := range []bool{false, true} {
path := filepath.Join(t.TempDir(), "tentacles.toml")
if broken {
if err := os.WriteFile(path, []byte("invalid"), 0600); err != nil {
t.Fatal(err)
}
}
runtime, _ := managed_tentacle_fixture(t, path, false)
runtime.newManager = func() (*tentacle.Manager, error) {
t.Error("empty/broken registry initialized host")
return nil, tentacle.ErrUnavailable
}
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
entry := probe_entry(t, "probe", "called")
err := runtime.Manage(context.Background(), tentacle.ManagementRequest{Action: "add", ID: entry.ID, Entry: &entry})
if (err != nil) != broken {
t.Fatalf("broken=%v: %v", broken, err)
}
if len(runtime.sessions) != 0 {
t.Fatal("launched unexpected child")
}
}
}
func TestPreferencesTentacleOperationsAreGlobalAndSeparate(t *testing.T) {
runtime, _ := managed_tentacle_fixture(t, filepath.Join(t.TempDir(), "tentacles.toml"), false)
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), config.DefaultSettings(), nil)
worker.tentacles = runtime
entry := probe_entry(t, "probe", "called")
request := preferences.Request{Tentacle: &tentacle.ManagementRequest{Action: "add", ID: entry.ID, Entry: &entry}}
worker.editApp = "Example"
if response := worker.manage_tentacle(context.Background(), request); response.Accepted {
t.Fatal("application preferences added a program")
}
worker.editApp = ""
request.Save = true
if response := worker.manage_tentacle(context.Background(), request); response.Accepted {
t.Fatal("mixed settings request accepted")
}
request.Save = false
if response := worker.manage_tentacle(context.Background(), request); !response.Accepted || response.Tentacles == nil || len(response.Tentacles.Entries) != 1 {
t.Fatalf("add: %+v", response)
}
}
func TestManagedTentacleGuardianPeer(t *testing.T) {
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "managed-guardian" {
return
}
if err := tentacle.RunGuardian(os.Stdin, os.Stdout, os.Args[len(os.Args)-1]); err != nil {
os.Exit(1)
}
os.Exit(0)
}
func TestNativeManagedTentacle(t *testing.T) {
if os.Getenv("CTHULHU_TENTACLE_MANAGED") != "1" {
t.Skip("requires a systemd user manager and cgroup v2")
}
runtime, bindings := managed_tentacle_fixture(t, filepath.Join(t.TempDir(), "tentacles.toml"), true)
readerGroup, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
t.Fatal(err)
}
runtime.logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
defer func() { t.Logf("final status: %+v", runtime.Snapshot()) }()
executable, _ := os.Executable()
runtime.guardianProgram = []string{executable, "-test.run=^TestManagedTentacleGuardianPeer$", "--", "managed-guardian"}
if binary := os.Getenv("CTHULHU_TENTACLE_GUARDIAN_BINARY"); binary != "" {
runtime.guardianProgram = []string{binary, "--tentacle-guardian"}
}
if err := runtime.Start(context.Background()); err != nil {
t.Fatal(err)
}
entry := probe_entry(t, "probe", "called")
entry.Enabled = true
manage_tentacle(t, runtime, "add", entry.ID, &entry)
wait_managed_state(t, runtime, entry.ID, "active")
runtime.Invoke(context.Background(), bindings.TentacleBindings()[0].Command)
marker := entry.Program[len(entry.Program)-1]
waitTentacle(t, func() bool { _, err := os.Stat(marker); return err == nil })
membership, err := os.ReadFile(marker)
if err != nil {
t.Fatal(err)
}
childGroup := filepath.Join("/sys/fs/cgroup", strings.TrimSpace(strings.TrimPrefix(string(membership), "0::")))
for name, want := range map[string]string{"cpu.max": "25000 100000", "memory.max": "268435456", "pids.max": "64"} {
value, err := os.ReadFile(filepath.Join(childGroup, name))
if err != nil || strings.TrimSpace(string(value)) != want {
t.Fatalf("%s: %q %v", name, value, err)
}
}
afterGroup, err := os.ReadFile("/proc/self/cgroup")
if err != nil || string(readerGroup) != string(afterGroup) {
t.Fatal("management moved the reader")
}
manage_tentacle(t, runtime, "disable", entry.ID, nil)
waitTentacle(t, func() bool { return len(bindings.TentacleBindings()) == 0 })
manage_tentacle(t, runtime, "enable", entry.ID, nil)
wait_managed_state(t, runtime, entry.ID, "active")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := runtime.Stop(ctx); err != nil {
t.Fatal(err)
}
serviceGroup := filepath.Dir(filepath.Dir(childGroup))
waitTentacle(t, func() bool { _, err := os.Stat(serviceGroup); return os.IsNotExist(err) })
}