okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(cli): extract local daemon lifecycled9c1a13

main
10.0 KiB253 linesraw
1// Discovery must distinguish an unsafe live endpoint from an absent daemon.
2// Malformed records and deliberate health failures require controlled peers.
3package localdaemon
4
5import (
6	"albedo/cli/internal/daemon"
7	"context"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"net"
12	"net/http"
13	"net/http/httptest"
14	"os"
15	"path/filepath"
16	"strings"
17	"testing"
18	"time"
19)
20
21const readyServer = `{"instance_id":"instance-a","protocol":3,"state":"ready","capabilities":{"durable_inputs":1,"session_replay":2,"collection_invalidation":1,"tool_progress":1},"build":"verified-build","digest":"verified-digest","extensions":[],"quota":[],"notices":[]}`
22
23func writeDiscovery(t *testing.T, home string, snapshot daemon.ConnectionSnapshot) {
24	t.Helper()
25	data, err := json.Marshal(snapshot)
26	if err != nil {
27		t.Fatal(err)
28	}
29	if err := os.WriteFile(filepath.Join(home, "daemon.json"), data, 0600); err != nil {
30		t.Fatal(err)
31	}
32}
33
34// A real daemon normally exits before cancellation can reach the stop wait.
35func TestUpgradeCancellationAfterShutdownDoesNotLaunchReplacement(t *testing.T) {
36	if processAlive(0) {
37		t.Skip("local process inspection is unsupported")
38	}
39	home := t.TempDir()
40	executable := filepath.Join(t.TempDir(), "replacement")
41	if err := os.WriteFile(executable, []byte("#!/bin/sh\n: > \"$ALBEDO_HOME/replacement-started\"\n"), 0700); err != nil {
42		t.Fatal(err)
43	}
44	t.Setenv("ALBEDO_DAEMON", executable)
45	shutdownAcknowledged := make(chan struct{})
46	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47		switch r.URL.Path {
48		case "/server":
49			_, _ = fmt.Fprint(w, readyServer)
50		case "/server/shutdown":
51			w.WriteHeader(202)
52			_, _ = fmt.Fprint(w, `{"instance_id":"instance-a","state":"draining"}`)
53			w.(http.Flusher).Flush()
54			close(shutdownAcknowledged)
55		default:
56			http.NotFound(w, r)
57		}
58	}))
59	defer server.Close()
60	snapshot := daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Pid: os.Getpid(), Token: "token", Version: daemon.ProtocolVersion, Build: "verified-build", Digest: "verified-digest", InstanceID: "instance-a"}
61	writeDiscovery(t, home, snapshot)
62	ctx, cancel := context.WithCancel(t.Context())
63	defer cancel()
64	finished := make(chan error, 1)
65	go func() {
66		_, err := Upgrade(ctx, LocalOptions{HomeDir: home}, snapshot)
67		finished <- err
68	}()
69	select {
70	case <-shutdownAcknowledged:
71	case <-time.After(time.Second):
72		t.Fatal("upgrade did not request shutdown")
73	}
74	// The acknowledged process deliberately remains alive, keeping Upgrade in
75	// its exit wait until the caller cancels.
76	select {
77	case err := <-finished:
78		t.Fatalf("upgrade returned before the live process exited: %v", err)
79	case <-time.After(50 * time.Millisecond):
80	}
81	cancel()
82	select {
83	case err := <-finished:
84		if !errors.Is(err, context.Canceled) {
85			t.Fatalf("upgrade lost cancellation: %v", err)
86		}
87	case <-time.After(time.Second):
88		t.Fatal("upgrade did not cancel its exit wait")
89	}
90	if _, err := os.Stat(filepath.Join(home, "replacement-started")); !errors.Is(err, os.ErrNotExist) {
91		t.Fatalf("canceled upgrade launched a replacement: %v", err)
92	}
93}
94
95func TestDiscoveryPreservesUnsafeRecordAndLiveHealthFailures(t *testing.T) {
96	t.Run("loaded healthy daemon", func(t *testing.T) {
97		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98			// Owner observations may queue behind work on an already ready daemon.
99			timer := time.NewTimer(750 * time.Millisecond)
100			defer timer.Stop()
101			select {
102			case <-timer.C:
103				_, _ = fmt.Fprint(w, readyServer)
104			case <-r.Context().Done():
105			}
106		}))
107		defer server.Close()
108		home := t.TempDir()
109		snapshot := daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Pid: os.Getpid(), Token: "token", Version: daemon.ProtocolVersion, Build: "verified-build", InstanceID: "instance-a"}
110		writeDiscovery(t, home, snapshot)
111		// The live resource supplies verified build metadata missing from the record.
112		snapshot.Digest = "verified-digest"
113		discovery, err := Discover(t.Context(), home)
114		if err != nil || discovery.Kind != Running || discovery.Snapshot != snapshot || discovery.Server.State != "ready" {
115			t.Fatalf("loaded healthy daemon was not usable: %+v, %v", discovery, err)
116		}
117	})
118	for _, status := range []int{http.StatusOK, http.StatusNotFound, http.StatusUnauthorized, http.StatusServiceUnavailable} {
119		t.Run(http.StatusText(status), func(t *testing.T) {
120			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121				w.WriteHeader(status)
122				if status == http.StatusOK {
123					_, _ = w.Write([]byte(`{`))
124					return
125				}
126				_, _ = w.Write([]byte(`{"type":"about:blank","title":"Unauthorized","status":401,"code":"authentication_required","detail":"health refused"}`))
127			}))
128			defer server.Close()
129			home := t.TempDir()
130			writeDiscovery(t, home, daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Pid: os.Getpid(), Token: "token", Version: daemon.ProtocolVersion})
131			_, err := Discover(t.Context(), home)
132			failure, ok := errors.AsType[*LocalError](err)
133			expected := UnhealthyDaemon
134			if status == http.StatusUnauthorized {
135				expected = AuthenticationFailed
136			}
137			if !ok || failure.Kind != expected {
138				t.Fatalf("health failure was treated as absence: %v", err)
139			}
140			if status == http.StatusOK {
141				if _, ok := errors.AsType[*daemon.ProtocolError](err); !ok {
142					t.Fatalf("malformed health lost its protocol failure: %v", err)
143				}
144			} else if api, ok := errors.AsType[*daemon.APIError](err); !ok || api.StatusCode != status {
145				t.Fatalf("health failure lost its HTTP status: %v", err)
146			}
147		})
148	}
149	home := t.TempDir()
150	if err := os.WriteFile(filepath.Join(home, "daemon.json"), []byte(`{`), 0600); err != nil {
151		t.Fatal(err)
152	}
153	_, err := Discover(t.Context(), home)
154	failure, ok := errors.AsType[*LocalError](err)
155	if !ok || failure.Kind != InvalidDiscovery {
156		t.Fatalf("malformed record was treated as absence: %v", err)
157	}
158}
159
160func TestDiscoveryRejectsChangedBuildDigest(t *testing.T) {
161	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
162		_, _ = fmt.Fprint(w, readyServer)
163	}))
164	t.Cleanup(server.Close)
165	home := t.TempDir()
166	writeDiscovery(t, home, daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Pid: os.Getpid(), Token: "token", Version: daemon.ProtocolVersion, Build: "verified-build", Digest: "different-digest"})
167	found, err := Discover(t.Context(), home)
168	local, ok := errors.AsType[*LocalError](err)
169	if !ok || local.Kind != InvalidDiscovery || found.Kind == Running {
170		t.Fatalf("changed executable identity accepted: %+v, %v", found, err)
171	}
172}
173
174// A protocol 2 daemon has no /server route; the current daemon cannot produce
175// this response, so a controlled peer checks migration diagnostics and refusal.
176func TestDiscoveryExplainsOlderProtocolWithoutTreatingItAsReady(t *testing.T) {
177	for _, status := range []int{http.StatusNotFound, http.StatusUnauthorized, http.StatusServiceUnavailable} {
178		t.Run(http.StatusText(status), func(t *testing.T) {
179			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180				if r.Method != http.MethodGet || r.URL.Path != "/server" || r.Header.Get("Authorization") != "Bearer token" {
181					t.Errorf("unexpected request while diagnosing an old daemon: %s %s", r.Method, r.URL.Path)
182				}
183				w.WriteHeader(status)
184			}))
185			t.Cleanup(server.Close)
186			home := t.TempDir()
187			snapshot := daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Pid: os.Getpid(), Token: "token", Version: 2}
188			writeDiscovery(t, home, snapshot)
189			found, err := Discover(t.Context(), home)
190			failure, ok := errors.AsType[*LocalError](err)
191			expected := UnhealthyDaemon
192			switch status {
193			case http.StatusNotFound:
194				expected = IncompatibleDaemon
195			case http.StatusUnauthorized:
196				expected = AuthenticationFailed
197			}
198			if !ok || failure.Kind != expected || found.Kind == Running {
199				t.Fatalf("old discovery record authorized attachment or lost its failure: %+v, %v", found, err)
200			}
201			if status == http.StatusNotFound {
202				compatibility, ok := errors.AsType[*daemon.CompatibilityError](err)
203				if !ok || compatibility.Version != 2 || !strings.Contains(err.Error(), "matching client (albedo daemon --stop)") {
204					t.Fatalf("missing actionable protocol mismatch: %v", err)
205				}
206			}
207			data, readErr := os.ReadFile(filepath.Join(home, "daemon.json"))
208			var retained daemon.ConnectionSnapshot
209			if readErr != nil || json.Unmarshal(data, &retained) != nil || retained != snapshot {
210				t.Fatal("discovery changed the old daemon record")
211			}
212		})
213	}
214}
215
216func TestDiscoveryAndLauncherWaitHonorCancellation(t *testing.T) {
217	for _, health := range []bool{false, true} {
218		t.Run(fmt.Sprintf("stalled_health_%t", health), func(t *testing.T) {
219			home := t.TempDir()
220			var operation func(context.Context) error
221			if health {
222				server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
223				defer server.Close()
224				writeDiscovery(t, home, daemon.ConnectionSnapshot{Port: server.Listener.Addr().(*net.TCPAddr).Port, Pid: os.Getpid(), Token: "t", Version: daemon.ProtocolVersion})
225				operation = func(ctx context.Context) error { _, err := Discover(ctx, home); return err }
226			} else {
227				if processAlive(0) {
228					t.Skip("advisory locking requires Unix")
229				}
230				executable := filepath.Join(t.TempDir(), "unused-daemon")
231				if err := os.WriteFile(executable, []byte("#!/bin/sh\nexit 99\n"), 0700); err != nil {
232					t.Fatal(err)
233				}
234				t.Setenv("ALBEDO_DAEMON", executable)
235				lock, err := acquireLauncher(t.Context(), home)
236				if err != nil {
237					t.Fatal(err)
238				}
239				defer lock.Close()
240				operation = func(ctx context.Context) error { _, err := Launch(ctx, LocalOptions{HomeDir: home}); return err }
241			}
242			ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
243			defer cancel()
244			started := time.Now()
245			if err := operation(ctx); !errors.Is(err, context.DeadlineExceeded) {
246				t.Fatalf("deadline lost: %v", err)
247			}
248			if time.Since(started) > 300*time.Millisecond {
249				t.Fatal("discovery or launcher wait ignored caller deadline")
250			}
251		})
252	}
253}