assets: set-assets and purge record the change message in the audit (task 10 fix round 1, part A)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4 files changed,
+205
−5
assets_cli_test.go+49 −0internal/console/apiassets.go+20 −3internal/console/apiassets_test.go+125 −0internal/console/apiinstances.go+11 −2
diff --git a/assets_cli_test.go b/assets_cli_test.go| index cf5b44663..f86101e7e 100644 |
| --- a/assets_cli_test.go |
| +++ b/assets_cli_test.go |
| @@ -31,6 +31,9 @@ func assetsDoor(t *testing.T, status int, reply string) (*apiSession, *struct{ M |
| // TestInstanceSetAssetsPatches pins instanceSetAssets's wire contract: a |
| // PATCH to the instance door carrying "assets" as the word "on" or "off", |
| // always sent, since the verb has no leave-it-alone spelling. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestInstanceSetAssetsPatches(t *testing.T) { |
| sess, got := assetsDoor(t, http.StatusOK, `{"host":"shop.example.com"}`) |
| acct := apiAcct{Seg: "abc"} |
| @@ -58,6 +61,9 @@ func TestInstanceSetAssetsPatches(t *testing.T) { |
| // TestInstanceSetAssetsOlderConsole: a console that predates the field |
| // answers 400 "nothing to update" for a body of only "assets"; the verb says |
| // the console is older instead of passing that on. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestInstanceSetAssetsOlderConsole(t *testing.T) { |
| sess, _ := assetsDoor(t, http.StatusBadRequest, `{"error":"nothing to update — send addrs, probe, steering, environment, gate, or a combination"}`) |
| _, err := sess.instanceSetAssets(context.Background(), apiAcct{Seg: "abc"}, "shop", "shop.example.com", false) |
| @@ -68,6 +74,9 @@ func TestInstanceSetAssetsOlderConsole(t *testing.T) { |
| // TestInstanceSetAssetsSurfacesRefusalVerbatim: any other refusal reaches the |
| // caller as the door worded it. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestInstanceSetAssetsSurfacesRefusalVerbatim(t *testing.T) { |
| sess, _ := assetsDoor(t, http.StatusBadRequest, `{"error":"assets must be on or off."}`) |
| _, err := sess.instanceSetAssets(context.Background(), apiAcct{Seg: "abc"}, "shop", "shop.example.com", true) |
| @@ -78,6 +87,9 @@ func TestInstanceSetAssetsSurfacesRefusalVerbatim(t *testing.T) { |
| // TestSetAssetsResultPrintsBothSentences pins EAC-10 and EAC-11 as a person |
| // reads them. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestSetAssetsResultPrintsBothSentences(t *testing.T) { |
| var b bytes.Buffer |
| printSetAssets(&b, "shop.example.com", true) |
| @@ -94,6 +106,9 @@ const purgePartialReply = `{"instances":[{"host":"a.shop.example.com","gen":"012 |
| // TestAssetsPurgePrintsComplete pins the wire (POST to the purge door) and |
| // EAC-12. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestAssetsPurgePrintsComplete(t *testing.T) { |
| sess, got := assetsDoor(t, http.StatusOK, purgeDoneReply) |
| res, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| @@ -115,6 +130,9 @@ func TestAssetsPurgePrintsComplete(t *testing.T) { |
| // TestAssetsPurgePrintsPartial: a purge that stopped part way names the |
| // instance that did not change and says to run it again (EAC-13). |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestAssetsPurgePrintsPartial(t *testing.T) { |
| sess, _ := assetsDoor(t, http.StatusOK, purgePartialReply) |
| res, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| @@ -134,6 +152,9 @@ func TestAssetsPurgePrintsPartial(t *testing.T) { |
| // TestAssetsPurgeOlderConsole: a 404 from the purge door is the mux of a |
| // console that has no such route, or an unknown app; the message says both, |
| // the form the restart verb uses. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestAssetsPurgeOlderConsole(t *testing.T) { |
| sess, _ := assetsDoor(t, http.StatusNotFound, "404 page not found\n") |
| _, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| @@ -155,6 +176,9 @@ const statusReply = `{"instances":[` + |
| // TestAssetsStatusPrintsTheBound pins the status wire (GET) and EAC-14: the |
| // column heads, a row per instance, and the bound sentence after them. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestAssetsStatusPrintsTheBound(t *testing.T) { |
| sess, got := assetsDoor(t, http.StatusOK, statusReply) |
| st, err := sess.assetsStatus(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| @@ -189,6 +213,9 @@ func TestAssetsStatusPrintsTheBound(t *testing.T) { |
| // TestAssetsStatusDecodesTheWire keeps the JSON field names the status door |
| // writes (internal/console assetStatusView) in step with the client's. |
| +// |
| +// Not acceptance: the console is a stand-in that returns canned JSON; the real |
| +// doors are tested in internal/console. |
| func TestAssetsStatusDecodesTheWire(t *testing.T) { |
| var st apiAssetsStatus |
| if err := json.Unmarshal([]byte(statusReply), &st); err != nil { |
| @@ -200,3 +227,25 @@ func TestAssetsStatusDecodesTheWire(t *testing.T) { |
| t.Fatalf("decoded = %+v", st) |
| } |
| } |
| + |
| +// TestAssetsVerbsSendTheMessage: a reason given to purge or set-assets rides |
| +// in the request body under "message", where the doors read it. |
| +// |
| +// Not acceptance: the console is a stand-in that records the request; the |
| +// real doors' audit rows are asserted in internal/console |
| +// (TestPurgeRecordsTheMessage, TestAPIInstanceUpdateRecordsTheMessage). |
| +func TestAssetsVerbsSendTheMessage(t *testing.T) { |
| + sess, got := assetsDoor(t, http.StatusOK, purgeDoneReply) |
| + if _, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop", "after the logo change"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Body != `{"message":"after the logo change"}` { |
| + t.Fatalf("purge body = %q", got.Body) |
| + } |
| + if _, err := sess.instanceSetAssets(context.Background(), apiAcct{Seg: "abc"}, "shop", "shop.example.com", false, "debugging"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Body != `{"assets":"off","message":"debugging"}` { |
| + t.Fatalf("set-assets body = %q", got.Body) |
| + } |
| +} |
diff --git a/internal/console/apiassets.go b/internal/console/apiassets.go| index 43f3368b5..dc509d3ab 100644 |
| --- a/internal/console/apiassets.go |
| +++ b/internal/console/apiassets.go |
| @@ -89,13 +89,27 @@ const assetPurgeWriteFailed = "could not update the instance record" |
| // record retired between the listing and its write is not an instance any |
| // more and is left out of the reply, placeAppTarget's rule. |
| // |
| -// One audit row names the instances changed and any not changed; the serial |
| -// is bumped once, after the writes, when any landed. |
| +// One audit row names the instances changed and any not changed, with the |
| +// caller's message; the serial is bumped once, after the writes, when any |
| +// landed. |
| func (s *Server) handleAPIAssetsPurge(w http.ResponseWriter, r *http.Request) { |
| principal, acct, app, ok := s.instanceGate(w, r) |
| if !ok { |
| return |
| } |
| + // The body is optional (the door took none before): a client that sends |
| + // one carries its reason as message, validated before any record is |
| + // written. |
| + var req struct { |
| + Message string `json:"message"` |
| + } |
| + if r.ContentLength != 0 && !readJSON(w, r, &req) { |
| + return |
| + } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| hosts, err := appRecordHosts(ctx, s.cfg.Releases, acct.Sqid, app) |
| @@ -129,7 +143,10 @@ func (s *Server) handleAPIAssetsPurge(w http.ResponseWriter, r *http.Request) { |
| if len(failed) > 0 { |
| detail += "; not changed: " + strings.Join(failed, ", ") |
| } |
| - s.audit(acct, app, principal, "assets-purge", detail+" (cli)") |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "assets-purge", TargetType: "app", Target: app, Message: message, |
| + Detail: detail + " (cli)", |
| + }) |
| } |
| if len(done) > 0 { |
| s.bumpSerial(ctx) |
diff --git a/internal/console/apiassets_test.go b/internal/console/apiassets_test.go| index 1c014c8b5..12afd62bb 100644 |
| --- a/internal/console/apiassets_test.go |
| +++ b/internal/console/apiassets_test.go |
| @@ -582,3 +582,128 @@ func TestAssetsStatusJoinsPublishedGeneration(t *testing.T) { |
| t.Fatalf("shop-2.example.com = %+v", two) |
| } |
| } |
| + |
| +// ---- the change message ----------------------------------------------------- |
| + |
| +// assetEvents is the app's audit rows for one action, newest first. |
| +func assetEvents(t *testing.T, fx *consoleFixture, action string) []Event { |
| + t.Helper() |
| + events, err := fx.st.Events(shipAssetsAccountID, assetsApp, 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []Event |
| + for _, e := range events { |
| + if e.Action == action { |
| + out = append(out, e) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestAPIInstanceUpdateRecordsTheMessage: the reason a person types with |
| +// set-assets reaches the audit row, beside the same detail as before, and a |
| +// message that is not one short line is refused before anything is written. |
| +func TestAPIInstanceUpdateRecordsTheMessage(t *testing.T) { |
| + fx, token := assetsFixture(t, "code-assets-patch-message") |
| + const host = "shop.example.com" |
| + putAssetRecord(t, fx, host, "0123456789abcdef") |
| + pu := fx.ts.URL + "/api/cli/apps/" + shipAssetsAccount + "/" + assetsApp + "/instances/" + host |
| + key := provision.RecordKey(shipAssetsAccount, assetsApp, host) |
| + |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"assets":"off","message":" debugging a stale logo "}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("patch = %d %s", status, out) |
| + } |
| + rows := assetEvents(t, fx, "instance-update") |
| + if len(rows) != 1 { |
| + t.Fatalf("instance-update rows = %+v, want one", rows) |
| + } |
| + if rows[0].Message != "debugging a stale logo" || rows[0].Target != host || rows[0].TargetType != "instance" || |
| + rows[0].Detail != "updated instance shop.example.com (assets) (cli)" { |
| + t.Fatalf("audit row = %+v, want the trimmed message, the instance target and the usual detail", rows[0]) |
| + } |
| + |
| + // A bad message: refused with the activity sentence, record, serial and |
| + // audit untouched. |
| + raw, _, err := fx.rel.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before := serial(t, fx) |
| + for _, bad := range []string{"two\\nlines", strings.Repeat("x", activityMessageMaxRunes+1)} { |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"assets":"on","message":"`+bad+`"}`) |
| + var body map[string]string |
| + _ = json.Unmarshal([]byte(out), &body) |
| + if status != http.StatusBadRequest || body["error"] != activityText.InvalidMessage { |
| + t.Fatalf("patch with message %.20q = %d %s, want 400 %q", bad, status, out, activityText.InvalidMessage) |
| + } |
| + } |
| + after, _, err := fx.rel.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(after) != string(raw) || serial(t, fx) != before || len(assetEvents(t, fx, "instance-update")) != 1 { |
| + t.Fatal("a refused message changed the record, the serial or the audit") |
| + } |
| + |
| + // No message: still a row, with none. |
| + if status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"assets":"on"}`); status != http.StatusOK { |
| + t.Fatalf("patch = %d %s", status, out) |
| + } |
| + if rows := assetEvents(t, fx, "instance-update"); len(rows) != 2 || rows[0].Message != "" { |
| + t.Fatalf("rows = %+v, want a second row with no message", rows) |
| + } |
| +} |
| + |
| +// TestPurgeRecordsTheMessage: the purge door reads its body, records the |
| +// message on the one audit row (partial or complete), refuses a bad message |
| +// before any record is written, and still takes no body at all. |
| +func TestPurgeRecordsTheMessage(t *testing.T) { |
| + fx, token := assetsFixture(t, "code-assets-purge-message") |
| + for i, host := range purgeHosts { |
| + putAssetRecord(t, fx, host, strings.Repeat(string(rune('a'+i)), 16)) |
| + } |
| + purge := func(body string) (int, string) { |
| + status, _, out := rawReq(t, http.MethodPost, assetsURL(fx)+"/purge", token, body) |
| + return status, out |
| + } |
| + |
| + // Refused first: nothing drawn, nothing audited. |
| + before := serial(t, fx) |
| + status, out := purge(`{"message":"two\nlines"}`) |
| + var body map[string]string |
| + _ = json.Unmarshal([]byte(out), &body) |
| + if status != http.StatusBadRequest || body["error"] != activityText.InvalidMessage { |
| + t.Fatalf("purge with a bad message = %d %s", status, out) |
| + } |
| + for i, host := range purgeHosts { |
| + if got := assetRecord(t, fx, host).AssetGen; got != strings.Repeat(string(rune('a'+i)), 16) { |
| + t.Fatalf("a refused purge changed %s to %q", host, got) |
| + } |
| + } |
| + if serial(t, fx) != before || len(assetEvents(t, fx, "assets-purge")) != 0 { |
| + t.Fatal("a refused purge bumped the serial or audited") |
| + } |
| + |
| + // A partial purge records the message too. |
| + fs := failRecords(fx) |
| + fs.fail(provision.RecordKey(shipAssetsAccount, assetsApp, purgeHosts[1])) |
| + if status, out := purge(`{"message":"after the logo change"}`); status != http.StatusOK { |
| + t.Fatalf("purge = %d %s", status, out) |
| + } |
| + rows := assetEvents(t, fx, "assets-purge") |
| + if len(rows) != 1 || rows[0].Message != "after the logo change" || rows[0].TargetType != "app" || rows[0].Target != assetsApp || |
| + !strings.Contains(rows[0].Detail, "not changed: "+purgeHosts[1]) { |
| + t.Fatalf("audit rows = %+v, want one with the message and the unchanged host", rows) |
| + } |
| + |
| + // No body at all, as before: a purge with no message. |
| + fs.fail("") |
| + if status, out := purge(``); status != http.StatusOK { |
| + t.Fatalf("bodyless purge = %d %s", status, out) |
| + } |
| + if rows := assetEvents(t, fx, "assets-purge"); len(rows) != 2 || rows[0].Message != "" { |
| + t.Fatalf("rows = %+v, want a second row with no message", rows) |
| + } |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index ab40380cb..aa5eeca82 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -2012,6 +2012,9 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| // Absent leaves the record alone. assetsSwitch (apiassets.go) reads |
| // it and holds the refusal for anything else. |
| Assets *string `json:"assets"` |
| + // Message is the caller's optional reason for the change, recorded on |
| + // the audit row (checkedActivityMessage owns its refusal). |
| + Message string `json:"message"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| @@ -2021,6 +2024,10 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| "nothing to update — send addrs, probe, steering, environment, gate, or a combination") |
| return |
| } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| var probe *provision.Health |
| if req.Probe != nil { |
| if err := json.Unmarshal(req.Probe, &probe); err != nil { |
| @@ -2206,8 +2213,10 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) |
| if req.Assets != nil { |
| changed = append(changed, "assets") |
| } |
| - s.audit(acct, app, principal, "instance-update", |
| - "updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)") |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "instance-update", TargetType: "instance", Target: host, Message: message, |
| + Detail: "updated instance " + host + " (" + strings.Join(changed, ", ") + ") (cli)", |
| + }) |
| // Strictly after the write landed, and exactly once: the record is what a |
| // box's reconcile leg converges upstreams from (provision.convergeOwned), |
| // so this announcement is what makes the new set live in seconds instead |