carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync/atomic"
16 "testing"
17
18 "github.com/carlosframework/platform/internal/hostbox"
19 "github.com/carlosframework/platform/internal/hostguard"
20)
21
22func TestPlan9JournalRootReadAndReceiptReplay(t *testing.T) {
23 j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db"))
24 if err != nil {
25 t.Fatal(err)
26 }
27 defer j.Close()
28 s, err := hostbox.NewJournalService(j)
29 if err != nil {
30 t.Fatal(err)
31 }
32 in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
33 q := hostbox.JournalRequest{V: 1, Op: "create", Launch: in}
34 q = plan9CanonicalJournalRequest(t, q)
35 if _, err = s.Execute(t.Context(), "box/enroll", q); err != nil {
36 t.Fatal(err)
37 }
38 root := ClaimRootHTTP{Journals: s, RequireNewWork: func(context.Context) error { return errors.New("new work blocked") }}
39 root.Reconcile.Box = "box"
40 root.Reconcile.Enrollment = "enroll"
41 peer := StopPeer{UID: 1001, PID: 17, ProcessStart: "23", BootID: "boot", ControllerID: "controller"}
42 // The receipt predates a subsequent claim change: the replay must still
43 // return the recorded result, without new claim authority or effects.
44 body, _ := json.Marshal(q)
45 response := httptest.NewRecorder()
46 root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
47 if response.Code != http.StatusOK {
48 t.Fatal(response.Code, response.Body.String())
49 }
50 q = hostbox.JournalRequest{V: 1, Op: "read", ID: "ffeeddccbbaa99887766554433221100", Target: in.Target}
51 body, _ = json.Marshal(q)
52 response = httptest.NewRecorder()
53 root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
54 var out hostbox.JournalResponse
55 if err = json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.Error != "" || out.Launch.Target.Host != in.Target.Host {
56 t.Fatalf("read: %+v %v", out, err)
57 } // Positive settlement is allowed after claim authority ends, but cannot
58 // invent a producer. No claim store is configured here deliberately.
59 if err = j.RecordProducer(t.Context(), in.Target, "issued"); err != nil {
60 t.Fatal(err)
61 }
62 root.Reconcile.Box = "box"
63 root.Reconcile.Enrollment = "enroll"
64 q = hostbox.JournalRequest{V: 1, Op: "complete", Target: in.Target, Producer: "issued"}
65 q = plan9CanonicalJournalRequest(t, q)
66 body, _ = json.Marshal(q)
67 response = httptest.NewRecorder()
68 root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
69 if err = json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.Error != "" || out.Completion.Count != 1 {
70 t.Fatalf("late completion: %+v %v", out, err)
71 }
72 root.Reconcile.Box = ""
73 root.Reconcile.Enrollment = ""
74 root.Reconcile.Box = "box"
75 root.Reconcile.Enrollment = "enroll"
76 next := in
77 next.Target.Session++
78 newRequest := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{Op: "create", Launch: next})
79 body, _ = json.Marshal(newRequest)
80 response = httptest.NewRecorder()
81 root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
82 if response.Code != http.StatusForbidden {
83 t.Fatalf("new-work gate bypassed: %d", response.Code)
84 }
85
86 // Root-only recovery settlement has no remotely callable operation.
87 q.Op = "settle-recovered"
88 q.ID = "11112222333344445555666677778888"
89 body, _ = json.Marshal(q)
90 response = httptest.NewRecorder()
91 root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
92 if response.Code == http.StatusOK {
93 t.Fatal("recovery authority exposed")
94 }
95 response = httptest.NewRecorder()
96 root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", strings.NewReader(`{"V":1,"Path":"/etc/passwd"}`)), peer)
97 if response.Code != http.StatusBadRequest {
98 t.Fatal("unknown path field accepted")
99 }
100}
101func TestPlan9JournalNamespaceUnitIsFlagScoped(t *testing.T) {
102 raw, err := unitsFS.ReadFile("units/" + claimControlUnit)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{}); !bytes.Equal(got, raw) {
107 t.Fatal("flag-off unit changed")
108 }
109 got := string(claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"}))
110 for _, want := range []string{"StateDirectory=carlos-claims", "StateDirectoryMode=0700", "/var/lib/carlos-claims", "/run/systemd/system"} {
111 if !strings.Contains(got, want) {
112 t.Fatalf("missing %s", want)
113 }
114 }
115}
116
117func plan9CanonicalJournalRequest(t *testing.T, q hostbox.JournalRequest) hostbox.JournalRequest {
118 t.Helper()
119 var captured hostbox.JournalRequest
120 client, _ := hostbox.RemoteJournals(func(_ context.Context, in hostbox.JournalRequest) (hostbox.JournalResponse, error) {
121 captured = in
122 return hostbox.JournalResponse{V: 1, ID: in.ID}, nil
123 })
124 var err error
125 switch q.Op {
126 case "create":
127 err = client.Create(t.Context(), q.Launch)
128 case "complete":
129 _, err = client.CompleteProducer(t.Context(), q.Target, q.Producer, q.Previous)
130 default:
131 t.Fatal("unsupported test request")
132 }
133 if err != nil {
134 t.Fatal(err)
135 }
136 return captured
137}
138
139func TestPlan9JournalTransportDefiniteRefusalAndLostReply(t *testing.T) {
140 for _, status := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusConflict} {
141 t.Run(fmt.Sprint(status), func(t *testing.T) {
142 var calls atomic.Int32
143 call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(status) }))
144 client, _ := hostbox.RemoteJournals(call)
145 err := client.Create(t.Context(), hostbox.LaunchInventory{})
146 var uncertain *hostbox.JournalUncertainError
147 if err == nil || errors.As(err, &uncertain) {
148 t.Fatalf("definite refusal became uncertain: %v", err)
149 }
150 if calls.Load() != 1 {
151 t.Fatalf("definite refusal retried %d times", calls.Load())
152 }
153 })
154 }
155 var calls atomic.Int32
156 call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157 if calls.Add(1) == 1 {
158 conn, _, err := w.(http.Hijacker).Hijack()
159 if err == nil {
160 conn.Close()
161 }
162 return
163 }
164 w.WriteHeader(http.StatusConflict)
165 }))
166 client, _ := hostbox.RemoteJournals(call)
167 err := client.Create(t.Context(), hostbox.LaunchInventory{})
168 var uncertain *hostbox.JournalUncertainError
169 if !errors.As(err, &uncertain) || calls.Load() != 2 {
170 t.Fatalf("lost first response was incorrectly settled: %v calls=%d", err, calls.Load())
171 }
172}
173func plan9JournalSocket(t *testing.T, handler http.Handler) hostbox.JournalCall {
174 t.Helper()
175 dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "journal-sock-")
176 if err != nil {
177 t.Fatal(err)
178 }
179 t.Cleanup(func() { os.RemoveAll(dir) })
180 path := filepath.Join(dir, "root.sock")
181 listener, err := net.Listen("unix", path)
182 if err != nil {
183 t.Fatal(err)
184 }
185 server := &http.Server{Handler: handler}
186 done := make(chan struct{})
187 go func() { defer close(done); _ = server.Serve(listener) }()
188 t.Cleanup(func() { server.Close(); <-done })
189 return rootJournalClient(path)
190}
191