284 lines
11 KiB
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) })
|
|
}
|