| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net" |
| 8 | "net/http" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/stopevidence" |
| 19 | ) |
| 20 | |
| 21 | // writersKernelFake records what root handed its kernel half. |
| 22 | type writersKernelFake struct { |
| 23 | stopKernelFunc |
| 24 | target hostguard.FenceTarget |
| 25 | peer StopPeer |
| 26 | hints []stopevidence.DescriptorHint |
| 27 | err error |
| 28 | calls int |
| 29 | } |
| 30 | |
| 31 | func (k *writersKernelFake) VerifyWritersStopped(_ context.Context, t hostguard.FenceTarget, _ StopInventory, peer StopPeer, hints []stopevidence.DescriptorHint) (WritersKernelEvidence, error) { |
| 32 | k.calls++ |
| 33 | k.target, k.peer, k.hints = t, peer, hints |
| 34 | if k.err != nil { |
| 35 | return WritersKernelEvidence{}, k.err |
| 36 | } |
| 37 | return WritersKernelEvidence{CheckedPaths: []string{"/data/shop/data.db"}, CgroupIdentities: []string{"cg:1:2"}, Retained: []stopevidence.Descriptor{{PID: peer.PID, TID: peer.PID, FD: 7, Device: 1, Inode: 2}}}, nil |
| 38 | } |
| 39 | |
| 40 | func writersFixtureCoordinator(t *testing.T) (ClaimStopCoordinator, WritersStoppedRequest, *writersKernelFake) { |
| 41 | t.Helper() |
| 42 | c, req := servingStopFixture(t) |
| 43 | kernel := &writersKernelFake{} |
| 44 | c.Kernel = kernel |
| 45 | w := WritersStoppedRequest{Region: req.Region, Host: req.Host, Incarnation: req.Incarnation, InstallationStamp: req.InstallationStamp, WriterSession: req.WriterSession, |
| 46 | Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, ExpectedClaimETag: req.ExpectedClaimETag, ExpectedDirectoryRevision: req.ExpectedDirectoryRevision, RosterRevision: req.RosterRevision, |
| 47 | Replicator: []stopevidence.DescriptorHint{{FD: 7, Device: 1, Inode: 2}}} |
| 48 | return c, w, kernel |
| 49 | } |
| 50 | |
| 51 | func TestWritersStoppedRootObservesWithItsOwnPeer(t *testing.T) { |
| 52 | c, req, kernel := writersFixtureCoordinator(t) |
| 53 | ev, err := c.WritersStopped(t.Context(), req) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | // The agent's process is the authenticated peer, never a request field. |
| 58 | if kernel.peer.PID != 42 || kernel.peer.ProcessStart != "start-42" || !slices.Equal(kernel.hints, req.Replicator) { |
| 59 | t.Fatalf("kernel got peer %+v hints %+v", kernel.peer, kernel.hints) |
| 60 | } |
| 61 | if kernel.target.Session != req.WriterSession || kernel.target.Host != req.Host || kernel.target.FenceID != "" { |
| 62 | t.Fatalf("kernel target = %+v", kernel.target) |
| 63 | } |
| 64 | if ev.Host != req.Host || ev.WriterSession != req.WriterSession || ev.ClaimETag != req.ExpectedClaimETag || ev.CheckedAt.IsZero() || len(ev.Retained) != 1 || ev.AgentPID != 42 { |
| 65 | t.Fatalf("evidence = %+v", ev) |
| 66 | } |
| 67 | // Never stop evidence: nothing is fenced or published. |
| 68 | keys, err := c.Evidence.List(t.Context(), "control/") |
| 69 | if err != nil || len(keys) != 0 { |
| 70 | t.Fatalf("writers-stopped published %v (%v)", keys, err) |
| 71 | } |
| 72 | called := false |
| 73 | if err := c.Guard.With(t.Context(), req.Host, hostguard.Captured{Incarnation: req.Incarnation, Stamp: req.InstallationStamp}, func(*hostclaim.Claim) error { return nil }, func() error { called = true; return nil }); err != nil || !called { |
| 74 | t.Fatalf("writers-stopped installed a fence: %v", err) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestWritersStoppedRefuses(t *testing.T) { |
| 79 | for name, tc := range map[string]struct { |
| 80 | change func(*ClaimStopCoordinator, *WritersStoppedRequest, *writersKernelFake) |
| 81 | want error |
| 82 | }{ |
| 83 | "another controller": {func(_ *ClaimStopCoordinator, r *WritersStoppedRequest, _ *writersKernelFake) { |
| 84 | r.ControllerID = "controller-2" |
| 85 | }, ErrClaimStopIdentity}, |
| 86 | "root's peer": {func(c *ClaimStopCoordinator, _ *WritersStoppedRequest, _ *writersKernelFake) { |
| 87 | c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) { |
| 88 | return StopPeer{UID: 0, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}, nil |
| 89 | }) |
| 90 | }, ErrClaimStopIdentity}, |
| 91 | "stale claim": {func(_ *ClaimStopCoordinator, r *WritersStoppedRequest, _ *writersKernelFake) { |
| 92 | r.ExpectedClaimETag = "etag-0" |
| 93 | }, ErrClaimStopIdentity}, |
| 94 | "another session": {func(_ *ClaimStopCoordinator, r *WritersStoppedRequest, _ *writersKernelFake) { r.WriterSession = 12 }, ErrClaimStopIdentity}, |
| 95 | "session zero": {func(_ *ClaimStopCoordinator, r *WritersStoppedRequest, _ *writersKernelFake) { r.WriterSession = 0 }, ErrClaimStopUnavailable}, |
| 96 | "another box": {func(_ *ClaimStopCoordinator, r *WritersStoppedRequest, _ *writersKernelFake) { r.Box = "box-2" }, ErrClaimStopIdentity}, |
| 97 | "another inventory": {func(_ *ClaimStopCoordinator, r *WritersStoppedRequest, _ *writersKernelFake) { |
| 98 | r.ExpectedDirectoryRevision = "x" |
| 99 | }, ErrClaimStopInventory}, |
| 100 | "kernel refuses": {func(_ *ClaimStopCoordinator, _ *WritersStoppedRequest, k *writersKernelFake) { |
| 101 | k.err = stopevidence.ErrForeignHolder |
| 102 | }, stopevidence.ErrForeignHolder}, |
| 103 | "no kernel half": {func(c *ClaimStopCoordinator, _ *WritersStoppedRequest, _ *writersKernelFake) { |
| 104 | c.Kernel = stopKernelFunc(nil) |
| 105 | }, ErrClaimStopUnavailable}, |
| 106 | } { |
| 107 | t.Run(name, func(t *testing.T) { |
| 108 | c, req, kernel := writersFixtureCoordinator(t) |
| 109 | tc.change(&c, &req, kernel) |
| 110 | if _, err := c.WritersStopped(t.Context(), req); !errors.Is(err, tc.want) { |
| 111 | t.Fatalf("got %v want %v", err, tc.want) |
| 112 | } |
| 113 | }) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestWritersStoppedTransportRoundTrip(t *testing.T) { |
| 118 | c, req, _ := writersFixtureCoordinator(t) |
| 119 | dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "ws") |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | defer os.RemoveAll(dir) |
| 124 | socket := filepath.Join(dir, "s") |
| 125 | l, err := net.Listen("unix", socket) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | peer := StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"} |
| 130 | h := ClaimStopHTTP{Coordinator: c} |
| 131 | srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 132 | if r.URL.Path != "/claim/writers-stopped" { |
| 133 | w.WriteHeader(http.StatusNotFound) |
| 134 | return |
| 135 | } |
| 136 | h.serveWritersStopped(w, r, peer) |
| 137 | }), ReadHeaderTimeout: time.Second} |
| 138 | go srv.Serve(l) |
| 139 | defer srv.Close() |
| 140 | ev, err := CollectWritersStopped(t.Context(), socket, req) |
| 141 | if err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | if ev.Host != req.Host || ev.WriterSession != req.WriterSession || len(ev.Retained) != 1 { |
| 145 | t.Fatalf("evidence = %+v", ev) |
| 146 | } |
| 147 | req.Host = "other.example" |
| 148 | if _, err := CollectWritersStopped(t.Context(), socket, req); err == nil { |
| 149 | t.Fatal("a refused observation passed") |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestWritersStoppedRequestIsStrictlyDecoded(t *testing.T) { |
| 154 | c, req, _ := writersFixtureCoordinator(t) |
| 155 | body, _ := json.Marshal(req) |
| 156 | body = append(body[:len(body)-1], []byte(`,"ReplicatorPID":1}`)...) |
| 157 | rec := &recordingWriter{header: http.Header{}} |
| 158 | r, _ := http.NewRequest(http.MethodPost, "http://control/claim/writers-stopped", strings.NewReader(string(body))) |
| 159 | ClaimStopHTTP{Coordinator: c}.serveWritersStopped(rec, r, StopPeer{UID: 1001, PID: 42, ProcessStart: "start-42", BootID: "boot-1", ControllerID: "controller-1"}) |
| 160 | if rec.status != http.StatusBadRequest { |
| 161 | t.Fatalf("an agent-chosen pid field was accepted: %d", rec.status) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | type recordingWriter struct { |
| 166 | header http.Header |
| 167 | status int |
| 168 | body []byte |
| 169 | } |
| 170 | |
| 171 | func (w *recordingWriter) Header() http.Header { return w.header } |
| 172 | func (w *recordingWriter) Write(b []byte) (int, error) { |
| 173 | if w.status == 0 { |
| 174 | w.status = http.StatusOK |
| 175 | } |
| 176 | w.body = append(w.body, b...) |
| 177 | return len(b), nil |
| 178 | } |
| 179 | func (w *recordingWriter) WriteHeader(s int) { w.status = s } |
| 180 | |