carlos / platform Public

settings-5: Astra r1 fixes (update report attribution, fast-track gate order, inherited major, named-unit services, copy inventory)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 40e7782e87af6fbd55a0ee816318b8d3b6de27a6 parent 561c7df
20 files changed, +441 −63
  • README.md +1 −1
  • apps_updates.go +7 −1
  • apps_updates_test.go +20 −0
  • docs/site/cli.md +1 −1
  • instances_reason_test.go +24 −0
  • instances_updates.go +2 −2
  • instances_updates_test.go +16 −0
  • internal/console/apiinstances.go +31 −27
  • internal/console/browserupdates.go +32 −19
  • internal/console/browserupdates_test.go +141 −0
  • internal/console/templates/app_instance.html +1 −1
  • internal/console/updatesjoin_test.go +101 −3
  • internal/host/updatesreport.go +5 −2
  • internal/host/updatesreport_test.go +24 −0
  • internal/status/contract_test.go +4 −2
  • internal/status/snapshot.go +7 −0
  • internal/status/status_test.go +3 −2
  • internal/updatewords/export_test.go +15 −0
  • internal/updatewords/words_docs_test.go +1 −0
  • internal/updatewords/words_surfaces.go +5 −2
diff --git a/README.md b/README.md
index 7130feee7..0a92a4838 100644
--- a/README.md
+++ b/README.md
@@ -280,7 +280,7 @@ the release you are about to roll.
**Pointer digests (#253): writers before readers.** Once a box has verified a channel pointer that carries manifest digests, it refuses pointers for that channel without them. So digests are turned on in order. First upgrade every writer: every console, and every CLI or deploy script that promotes, rolls back or fast-tracks. Then run `carlos channels digests on`. Then watch `carlos channels digests`: a channel marked as written by an old writer means one is still in use. Rolling a writer back below this release stops it writing pointers boxes accept; `carlos channels digests off` suspends digests while it is fixed, but does not clear the boxes that have already seen them. When `carlos channels digests` lists nothing, set `CARLOS_REQUIRE_POINTER_DIGEST=on` in every box's host.env, so a new box refuses old pointers too.
-**Instance update settings: the roll order.** Roll every box to the release that applies update settings before anyone turns on manual updates, a major hold or a pin: the console refuses those until every box reports it can protect the builds they name from pruning, and lists the boxes it is waiting on. Before rolling any box back below that release, turn retention off for the deployment (`carlos releases retention --off` for each app that has one), because an older housekeeper prunes builds a pin or a held instance still needs. After such a rollback the console shows *settings not enforced* for instances on the older boxes: they follow their channel until the box is rolled forward again.
+**Instance update settings: the roll order.** Roll every box to the release that applies update settings before anyone turns on manual updates, a major hold or a pin: the console refuses those until every box reports it can protect the builds they name from pruning, and says how many boxes it is waiting on. Before rolling any box back below that release, turn retention off for the deployment (`carlos releases retention --off` for each app that has one), because an older housekeeper prunes builds a pin or a held instance still needs. After such a rollback the console shows *settings not enforced* for instances on the older boxes: they follow their channel until the box is rolled forward again.
**v0.25.0: instances move to the nested layout on their own.** This is
the first release on which an isolated (nested) instance wakes, and nested
diff --git a/apps_updates.go b/apps_updates.go
index d69b2a308..cd3415e89 100644
--- a/apps_updates.go
+++ b/apps_updates.go
@@ -15,6 +15,7 @@ import (
"strconv"
"strings"
+ "github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/updatewords"
)
@@ -118,12 +119,17 @@ func appsSetRun(ctx context.Context, sess *apiSession, acct apiAcct, app string,
}
return s
}
+ // An unset default is auto: the setting's own value word, not copy.
+ updates := got.DefaultUpdates
+ if updates == "" {
+ updates = release.UpdatesAuto
+ }
major := none
if got.DefaultMajor != nil {
major = strconv.Itoa(*got.DefaultMajor)
}
fmt.Fprintf(stdout, updatewords.Text("settings.cli.apps-set-policy-done")+"\n", app,
- or(strings.Join(got.InstanceControl, ", ")), or(got.DefaultUpdates), or(got.DefaultChannel), major)
+ or(strings.Join(got.InstanceControl, ", ")), updates, or(got.DefaultChannel), major)
}
return nil
}
diff --git a/apps_updates_test.go b/apps_updates_test.go
index 5aba6c91d..df1a79e84 100644
--- a/apps_updates_test.go
+++ b/apps_updates_test.go
@@ -251,3 +251,23 @@ func TestAppsSetRunNothingToSet(t *testing.T) {
t.Fatalf("a request went out: %v", d.got())
}
}
+
+// Astra r1 triage: an app with no default updates has instances update
+// automatically, so the line says the setting's own value word, auto, not
+// "none". Unset lists, channels and majors still read none.
+func TestAppsSetPolicyDoneSaysAutoForUnsetDefaultUpdates(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{}`)
+ }))
+ t.Cleanup(ts.Close)
+ out := captureStdout(t)
+ if err := appsSetRun(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "shop", nil, updatePolicyReq{"default_updates": ""}); err != nil {
+ t.Fatal(err)
+ }
+ none := updatewords.Text("settings.cli.none")
+ want := fmt.Sprintf(updatewords.Text("settings.cli.apps-set-policy-done")+"\n", "shop", none, "auto", none, none)
+ if out.String() != want {
+ t.Fatalf("stdout = %q, want %q", out.String(), want)
+ }
+}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index d4bd04051..e2bc11208 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -205,7 +205,7 @@ console.
carlos apps set shop --instance-control updates,pin --default-updates manual
```
---instance-control names the settings an instance may change itself once instances can act for themselves; --default-updates, --default-channel and --default-major apply wherever an instance has not chosen.
+--instance-control names the settings an instance may change itself once instances can act for themselves. --default-updates and --default-major apply wherever an instance has not chosen; --default-channel is the channel new instances are created on.
### carlos ship
diff --git a/instances_reason_test.go b/instances_reason_test.go
index 3f40bac5f..28923c0ce 100644
--- a/instances_reason_test.go
+++ b/instances_reason_test.go
@@ -79,3 +79,27 @@ func TestReasonCellOrder(t *testing.T) {
}
}
}
+
+// Astra r1 (R18): a report the console cannot attribute to this instance's
+// tenant reaches the list as whether the box enforces settings and nothing
+// else. Enforced, it says nothing; not enforced, a non-default record still
+// reads "settings not enforced".
+func TestAnEnforcedOnlyReportReadsCorrectly(t *testing.T) {
+ for _, c := range []struct {
+ body string
+ want string
+ }{
+ {`{"host":"h","settings_default":false,"update_report":{"enforced":true}}`, ""},
+ {`{"host":"h","settings_default":true,"update_report":{"enforced":true}}`, ""},
+ {`{"host":"h","settings_default":false,"update_report":{"enforced":false}}`, updatewords.Text("settings.cli.not-enforced-reason")},
+ {`{"host":"h","settings_default":true,"update_report":{"enforced":false}}`, ""},
+ } {
+ var r apiInstanceRecord
+ if err := json.Unmarshal([]byte(c.body), &r); err != nil {
+ t.Fatalf("decode %s: %v", c.body, err)
+ }
+ if got := reasonCell(r); got != c.want {
+ t.Errorf("%s: reasonCell = %q, want %q", c.body, got, c.want)
+ }
+ }
+}
diff --git a/instances_updates.go b/instances_updates.go
index 9dc79ab78..e7055e50d 100644
--- a/instances_updates.go
+++ b/instances_updates.go
@@ -103,7 +103,7 @@ func runInstancesSet(args []string) {
pin := fs.String("pin", "", updatewords.Text("settings.cli.set-flag-pin"))
consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
- acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY โ€” supersedes --account")
+ acctIDRaw := fs.String("account-id", "", updatewords.Text("settings.cli.flag-account-id"))
usage := updatewords.Text("settings.cli.set-usage")
fs.Usage = flagUsage(fs, usage)
fs.Parse(args)
@@ -205,7 +205,7 @@ func runInstancesUpdate(args []string) {
fast := fs.Bool("fast-track", false, updatewords.Text("settings.cli.update-flag-fast-track"))
consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)")
acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
- acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY โ€” supersedes --account")
+ acctIDRaw := fs.String("account-id", "", updatewords.Text("settings.cli.flag-account-id"))
usage := updatewords.Text("settings.cli.update-usage")
fs.Usage = flagUsage(fs, usage)
fs.Parse(args)
diff --git a/instances_updates_test.go b/instances_updates_test.go
index e858d2bb4..4a6b7e107 100644
--- a/instances_updates_test.go
+++ b/instances_updates_test.go
@@ -419,3 +419,19 @@ func runCarlosEnv(t *testing.T, bin string, args []string, extra ...string) (str
out, err := cmd.CombinedOutput()
return string(out), err
}
+
+// Astra r1 (R21): both verbs' help is the reviewed copy, with no em dash.
+// --account-id reads the shared draft (apps_swap.go keeps its pinned
+// literal).
+func TestInstancesSetAndUpdateHelpHasNoEmDash(t *testing.T) {
+ bin := buildCarlos(t)
+ for _, verb := range []string{"set", "update"} {
+ out, _ := runCarlosEnv(t, bin, []string{"instances", verb, "-h"})
+ if !strings.Contains(out, updatewords.Text("settings.cli.flag-account-id")) {
+ t.Errorf("instances %s -h lacks the --account-id draft:\n%s", verb, out)
+ }
+ if strings.ContainsRune(out, '\u2014') {
+ t.Errorf("instances %s -h has an em dash:\n%s", verb, out)
+ }
+ }
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index dd75bef58..5616cb13d 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -354,11 +354,10 @@ type boxJoin struct {
}
// updatesJoinView is one host's update report as a member sees it. account
-// and app are the tenant the SAME box's rollout report gives for the host
-// (status.HostRollout.Account and .App), absent when the box has no rollout
-// report for it; platform says the box is one of the platform's own. The
-// report itself (status.HostUpdates) names no tenant, so these two decide
-// whether it may be joined (update).
+// and app are the report's tenant: the entry's own (status.HostUpdates.Account
+// and .App) when it names one, else the one the SAME box's rollout report
+// gives for the host (status.HostRollout.Account and .App), else none. They
+// decide whether the report may be joined (update).
type updatesJoinView struct {
Desired string `json:"desired,omitempty"`
Reason string `json:"reason,omitempty"`
@@ -373,26 +372,27 @@ type updatesJoinView struct {
// nothing, and an empty view must not read as "following its channel".
Enforced bool `json:"enforced"`
account, app string
- platform bool
}
-// update is the join's answer for host, or nil unless the report may be this
-// account's and this app's. The cross-tenant rule is rollout's: a box's
-// report about a host never surfaces on another tenant's list. A view whose
-// box names a tenant for the host must name this one; a view whose box names
-// none is accepted only from a platform box, whose hosts are unique and which
-// the caller has filtered to its own account and app's records. A customer
-// fleet box that names no tenant is refused.
+// update is the join's answer for host, or nil when the box has nothing on
+// it or names another tenant for it. The cross-tenant rule is rollout's: a
+// box's report about a host never surfaces on another tenant's list or page.
+// A report is this account's and this app's only when its tenant is known
+// and is this one (Astra r1, R18). Owning the hostname now is not enough: a
+// snapshot stays fresh for minutes, and the host may have been another
+// tenant's when the box wrote it, on a platform box as on a fleet box. A
+// report with no known tenant yields whether the box enforces settings and
+// nothing else, so an old box still reads "settings not enforced" and nothing
+// about any instance crosses.
func (j boxJoin) update(host, acct, app string) *updatesJoinView {
v, ok := j.updates[host]
if !ok {
return nil
}
- if v.account != "" || v.app != "" {
- if v.account != acct || v.app != app {
- return nil
- }
- } else if !v.platform {
+ if v.account == "" && v.app == "" {
+ return &updatesJoinView{Enforced: v.Enforced}
+ }
+ if v.account != acct || v.app != app {
return nil
}
return &v
@@ -610,7 +610,7 @@ func joinBoxes(boxes []status.Box, now time.Time) boxJoin {
j.dbBytes[host] = n
}
j.foldRollout(b, true)
- j.foldUpdates(b, true, true)
+ j.foldUpdates(b, true)
}
return j
}
@@ -619,8 +619,9 @@ func joinBoxes(boxes []status.Box, now time.Time) boxJoin {
// override rule (a fleet box never replaces a host a platform box already
// reported). Every host the box serves gets a view saying whether the box
// enforces settings; the hosts it reports on carry the report. The tenant is
-// whatever the same box's rollout report names for the host.
-func (j *boxJoin) foldUpdates(b status.Box, override, platform bool) {
+// the report's own, else whatever the same box's rollout report names for
+// the host (updatesJoinView).
+func (j *boxJoin) foldUpdates(b status.Box, override bool) {
m := b.Metrics
enforced := m.UpdateSettings >= status.UpdateSettingsProtocol
if j.updates == nil {
@@ -630,16 +631,19 @@ func (j *boxJoin) foldUpdates(b status.Box, override, platform bool) {
if _, had := j.updates[host]; had && !override {
return
}
- v := updatesJoinView{Enforced: enforced, platform: platform}
- if u, ok := m.Updates[host]; ok {
- v.Desired, v.Reason, v.Detail, v.Baking, v.Major = u.Desired, u.Reason, u.Detail, u.Baking, u.Major
- v.Pending, v.Completed, v.CompletedFailed = u.Pending, u.Completed, u.CompletedFailed
- }
+ v := updatesJoinView{Enforced: enforced}
if m.Rollout != nil {
if hr, ok := m.Rollout.Hosts[host]; ok {
v.account, v.app = hr.Account, hr.App
}
}
+ if u, ok := m.Updates[host]; ok {
+ v.Desired, v.Reason, v.Detail, v.Baking, v.Major = u.Desired, u.Reason, u.Detail, u.Baking, u.Major
+ v.Pending, v.Completed, v.CompletedFailed = u.Pending, u.Completed, u.CompletedFailed
+ if u.Account != "" || u.App != "" {
+ v.account, v.app = u.Account, u.App
+ }
+ }
j.updates[host] = v
}
for host := range m.RouteVersions {
@@ -719,7 +723,7 @@ func (j *boxJoin) joinFleetRollouts(boxes []status.Box, now time.Time) {
continue
}
j.foldRollout(b, false)
- j.foldUpdates(b, false, false)
+ j.foldUpdates(b, false)
}
}
diff --git a/internal/console/browserupdates.go b/internal/console/browserupdates.go
index 2be8828f7..49f3e686e 100644
--- a/internal/console/browserupdates.go
+++ b/internal/console/browserupdates.go
@@ -59,23 +59,32 @@ type updatesPageView struct {
ShowMajor bool // only stable cuts majors
MajorLive bool
MajorInput, MajorShown string
- PinLive bool
- PinInput, PinShown string
+ // MajorInherited is the note beside a live major box when the instance
+ // has no hold of its own and the app's default holds it (Astra r1, R20).
+ // The box stays the instance's own override: empty means inherit.
+ MajorInherited string
+ PinLive bool
+ PinInput, PinShown string
}
// updatesPage builds the section for host, or nil when there is none to show:
// no record, a record that is not an instance, or a read that failed (logged
-// by readUpdatesTarget; the page soft-degrades, never a 500).
+// by readUpdatesTarget; the page soft-degrades, never a 500). A record run by
+// its own systemd unit shows plan 3's named-unit words whatever its kind
+// (Astra r1, R21), as the API answers them before its kind refusal.
func (s *Server) updatesPage(ctx context.Context, acct *Account, app, host string, d *instanceDetail, report *updatesJoinView, owner bool) *updatesPageView {
ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout)
defer cancel()
rec, _, policy, st, _ := s.readUpdatesTarget(ctx, acct, app, host)
- if st != 0 || rec.KindOrDefault() != "instance" {
+ if st != 0 {
return nil
}
if rec.Unit != "" {
return &updatesPageView{NamedUnit: fmt.Sprintf(updatewords.Text("settings.console.named-unit"), host)}
}
+ if rec.KindOrDefault() != "instance" {
+ return nil
+ }
v := s.updatesViewOf(ctx, *rec, policy)
none := updatewords.Text("settings.cli.none")
live := func(name string) bool { return slices.Contains(v.Control, name) }
@@ -92,6 +101,9 @@ func (s *Server) updatesPage(ctx context.Context, acct *Account, app, host strin
}
if v.Settings.Major != nil {
pv.MajorShown = strconv.Itoa(*v.Settings.Major)
+ if v.Explicit.Major == nil {
+ pv.MajorInherited = fmt.Sprintf(updatewords.Text("settings.page.major-inherited"), *v.Settings.Major)
+ }
}
if v.Settings.Pin != "" {
pv.PinShown = v.Settings.Pin
@@ -194,8 +206,11 @@ func updatesFormRequest(f url.Values) map[string]any {
// updatesFormTarget is both forms' gate, in the console's form security
// order (AGENTS.md; handleInstanceRetry's shape): session, CSRF, the app's
-// byte-identical 404, the record's 404, then the shared write budget. ok=false
-// means a response has been written. back is the instance page.
+// byte-identical 404, the record's 404, for Update now the fast-track
+// owner's 404, then the shared write budget. The owner check comes before the
+// budget (Astra r1, R19): a member asking to skip the safety delay gets the
+// byte-identical 404 whatever the budget's state. ok=false means a response
+// has been written. back is the instance page.
type updatesFormTarget struct {
addr, raw, role, app, host, back string
acct *Account
@@ -204,7 +219,7 @@ type updatesFormTarget struct {
policy *provision.UpdatePolicy
}
-func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx context.Context) (updatesFormTarget, bool) {
+func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx context.Context, updateNow bool) (updatesFormTarget, bool) {
addr, raw, acct, role, ok := s.account(w, r)
if !ok {
return updatesFormTarget{}, false
@@ -236,6 +251,10 @@ func (s *Server) updatesFormGate(w http.ResponseWriter, r *http.Request, ctx con
return updatesFormTarget{}, false
}
t.rec, t.etag, t.policy = rec, etag, policy
+ if updateNow && r.PostFormValue("fast_track") == "1" && role != RoleOwner {
+ http.NotFound(w, r)
+ return updatesFormTarget{}, false
+ }
if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) {
s.updatesFlash(w, r, t.back, "err", updatewords.Text("settings.page.rate-limited"))
return updatesFormTarget{}, false
@@ -253,7 +272,7 @@ func (s *Server) updatesFlash(w http.ResponseWriter, r *http.Request, back, kind
func (s *Server) handleBrowserUpdates(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
- t, ok := s.updatesFormGate(w, r, ctx)
+ t, ok := s.updatesFormGate(w, r, ctx, false)
if !ok {
return
}
@@ -270,24 +289,18 @@ func (s *Server) handleBrowserUpdates(w http.ResponseWriter, r *http.Request) {
// handleBrowserUpdateNow is the Updates section's "Update now": POST
// /a/{sqid}/apps/{app}/instances/{host}/updates/install. Skipping the safety
// delay is an account owner's, as at the console's fast-track door: anyone
-// else gets that door's byte-identical 404, and an owner proves presence
-// (requireFreshAuth, last).
+// else gets that door's byte-identical 404 (in the gate, before the write
+// budget), and an owner proves presence (requireFreshAuth, last).
func (s *Server) handleBrowserUpdateNow(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
- t, ok := s.updatesFormGate(w, r, ctx)
+ t, ok := s.updatesFormGate(w, r, ctx, true)
if !ok {
return
}
fast := r.PostFormValue("fast_track") == "1"
- if fast {
- if t.role != RoleOwner {
- http.NotFound(w, r)
- return
- }
- if !s.requireFreshAuth(w, r, t.addr, t.raw) {
- return
- }
+ if fast && !s.requireFreshAuth(w, r, t.addr, t.raw) {
+ return
}
_, st, msg := s.requestInstall(ctx, t.acct, t.app, t.addr, t.host, fast)
switch {
diff --git a/internal/console/browserupdates_test.go b/internal/console/browserupdates_test.go
index 3b1458598..336da6022 100644
--- a/internal/console/browserupdates_test.go
+++ b/internal/console/browserupdates_test.go
@@ -118,6 +118,17 @@ func (fx *pageFx) boxesEnforce(on bool) {
}
}
+// writeBox1 replaces box1's snapshot with m: no rollout report unless m
+// carries one.
+func (fx *pageFx) writeBox1(m *status.BoxMetrics) {
+ fx.t.Helper()
+ box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
+ box.Metrics = m
+ if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
+ fx.t.Fatal(err)
+ }
+}
+
func (fx *pageFx) record(host string) provision.Record {
fx.t.Helper()
rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "kass", host)
@@ -279,6 +290,22 @@ func TestUpdatesSectionForANamedUnit(t *testing.T) {
}
}
+// Astra r1 (R21): a named unit's explanation does not depend on the record's
+// kind. A service-kind record run by its own unit shows the same words, and
+// no form.
+func TestUpdatesSectionForANamedUnitService(t *testing.T) {
+ fx := updatesPageFixture(t)
+ fx.grant("updates", "major", "pin")
+ fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Kind, r.Unit, r.Addr = "service", "console.service", "127.0.0.1:8080" })
+ c := signInPaul(t, fx.consoleFixture)
+ code, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ sec := updatesSection(body)
+ if code != http.StatusOK || !strings.Contains(sec, esc(fmt.Sprintf(updatewords.Text("settings.console.named-unit"), heldHost))) ||
+ strings.Contains(sec, "<form") || strings.Contains(sec, esc(updatewords.Text("settings.page.update-now"))) {
+ t.Fatalf("named-unit service page = %d, section:\n%s", code, sec)
+ }
+}
+
// R12: a record of another kind shows no Updates section at all.
func TestUpdatesSectionIsAbsentForOtherKinds(t *testing.T) {
fx := updatesPageFixture(t)
@@ -384,6 +411,22 @@ func TestFastTrackIsAnOwnersFromThePage(t *testing.T) {
if code != http.StatusNotFound || out != "404 page not found\n" || fx.record(heldHost).InstallRequest != nil {
t.Fatalf("member fast track = %d %q", code, out)
}
+ // Astra r1 (R19): with the write budget spent, the answer is still the
+ // owner 404, byte-identical to an unknown host's, not the rate-limit
+ // flash. The same budget refuses a plain Update now, so it is spent.
+ fx.srv.cli.write = newRateLimiter(1, time.Hour)
+ if _, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), url.Values{"csrf": {csrf}, "form": {"updates"}}); loc == "" {
+ t.Fatal("the no-op save that spends the budget did not redirect")
+ }
+ _, _, unknown := postForm(t, c, fx.u("/apps/kass/instances/nope.kass.carlosapp.dev/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}})
+ code, _, out = postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}, "fast_track": {"1"}})
+ if code != http.StatusNotFound || out != unknown || out != "404 page not found\n" || fx.record(heldHost).InstallRequest != nil {
+ t.Fatalf("member fast track on a spent budget = %d %q (unknown host %q), record %+v", code, out, unknown, fx.record(heldHost).InstallRequest)
+ }
+ code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates/install"), url.Values{"csrf": {csrf}})
+ if _, page := fx.getPage(c, loc); code != http.StatusSeeOther || !strings.Contains(page, esc(updatewords.Text("settings.page.rate-limited"))) {
+ t.Fatalf("plain Update now on the spent budget = %d, not rate-limited:\n%s", code, updatesSection(page))
+ }
}
// Astra I3 #8: with the fleet gate closed, a member can still switch auto
@@ -658,3 +701,101 @@ func TestUpdatesSectionPreview(t *testing.T) {
}
t.Logf("wrote %s", out)
}
+
+// Astra r1 (R18), through the instance page: box1 is a platform box with no
+// rollout report (CARLOS_SWAP off) whose update report for heldHost is still
+// the previous tenant's. kass's page shows no status line, no reason and no
+// build from it. An entry naming kass is kass's; one naming anyone else is
+// never shown.
+func TestAReassignedHostShowsNoPreviousTenantsReportOnThePage(t *testing.T) {
+ fx := updatesPageFixture(t)
+ c := signInPaul(t, fx.consoleFixture)
+ statusPrefix := esc(strings.SplitN(updatewords.Text("settings.page.status"), "%s", 2)[0])
+ box := func(tenant [2]string) *status.BoxMetrics {
+ return &status.BoxMetrics{
+ UpdateSettings: status.UpdateSettingsProtocol,
+ RouteVersions: map[string]string{heldHost: "v2"},
+ Updates: map[string]status.HostUpdates{heldHost: {Desired: "v-prev-desired", Reason: "pin", Detail: "v-prev-pin", Baking: "v-prev-baking",
+ Account: tenant[0], App: tenant[1]}},
+ }
+ }
+ for _, tenant := range [][2]string{{"", ""}, {"cdf", "kass"}, {fx.sqid, "blog"}} {
+ fx.writeBox1(box(tenant))
+ _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ sec := updatesSection(body)
+ if sec == "" {
+ t.Fatalf("tenant %v: no Updates section:\n%s", tenant, body)
+ }
+ for _, leak := range []string{"v-prev-desired", "v-prev-pin", "v-prev-baking", statusPrefix} {
+ if strings.Contains(body, leak) {
+ t.Errorf("tenant %v: the page shows %q:\n%s", tenant, leak, sec)
+ }
+ }
+ }
+ fx.writeBox1(box([2]string{fx.sqid, "kass"}))
+ _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ reason := fmt.Sprintf(updatewords.Text("settings.box.reason.pin"), "v-prev-pin") + "; " + fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), "v-prev-baking")
+ if want := esc(fmt.Sprintf(updatewords.Text("settings.page.status"), "v2", updatewords.Text("settings.cli.none"), "v-prev-desired", reason)); !strings.Contains(updatesSection(body), want) {
+ t.Fatalf("kass's own attributed report: status line %q not in:\n%s", want, updatesSection(body))
+ }
+}
+
+// R18: with no tenant to attribute the report to, an old box still says the
+// settings are not enforced; that is the box's, not the instance's.
+func TestATenantlessOldBoxStillSaysNotEnforced(t *testing.T) {
+ fx := updatesPageFixture(t)
+ fx.writeBox1(&status.BoxMetrics{RouteVersions: map[string]string{heldHost: "v2"},
+ Updates: map[string]status.HostUpdates{heldHost: {Desired: "v-prev-desired", Reason: "manual", Detail: "v-prev-desired"}}})
+ fx.setRecordUpdates(heldHost, "manual")
+ c := signInPaul(t, fx.consoleFixture)
+ _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ sec := updatesSection(body)
+ if !strings.Contains(sec, esc(updatewords.Text("settings.page.not-enforced"))) || strings.Contains(body, "v-prev-desired") {
+ t.Fatalf("section:\n%s", sec)
+ }
+}
+
+// Astra r1 (R20): the major box is the instance's own override, so it is
+// empty while the app's default holds the instance; the note beside it says
+// the app holds it and at which major. Clearing an override back to the
+// app's default works with the fleet gate closed, and the note comes back.
+func TestAnInheritedMajorHoldIsShownBesideTheBox(t *testing.T) {
+ fx := updatesPageFixture(t)
+ two, three := 2, 3
+ enableInstances(t, fx.consoleFixture, "kass", provision.Manifest{Schema: 1, App: "kass", Domain: "kass.carlosapp.dev", Channel: "stable",
+ Updates: &provision.UpdatePolicy{InstanceControl: []string{"updates", "major", "pin"}, DefaultMajor: &two}})
+ fx.boxesEnforce(false) // the fleet gate is closed
+ fx.setChannel(heldHost, "stable")
+ note := esc(fmt.Sprintf(updatewords.Text("settings.page.major-inherited"), 2))
+ c := signInPaul(t, fx.consoleFixture)
+ section := func() string {
+ t.Helper()
+ _, body := fx.getPage(c, fx.u("/apps/kass/instances/"+heldHost))
+ return updatesSection(body)
+ }
+ if sec := section(); !strings.Contains(sec, note) || !strings.Contains(sec, `name="major" value=""`) || !strings.Contains(sec, `name="was_major" value=""`) {
+ t.Fatalf("inherited hold: section lacks the note %q or an empty override:\n%s", note, sec)
+ }
+ fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Major = &three })
+ sec := section()
+ if strings.Contains(sec, note) || !strings.Contains(sec, `name="major" value="3"`) || !strings.Contains(sec, `name="was_major" value="3"`) {
+ t.Fatalf("explicit hold: the note shows or the override is missing:\n%s", sec)
+ }
+ f := browserForm(t, sec)
+ f.Set("major", "") // back to the app's default
+ code, loc, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), f)
+ if _, page := fx.getPage(c, loc); code != http.StatusSeeOther || !strings.Contains(page, esc(updatewords.Text("settings.page.saved"))) {
+ t.Fatalf("clearing the override = %d:\n%s", code, page)
+ }
+ if r := fx.record(heldHost); r.Major != nil {
+ t.Fatalf("the override survived: %+v", r)
+ }
+ if sec := section(); !strings.Contains(sec, note) || !strings.Contains(sec, `name="major" value=""`) {
+ t.Fatalf("after clearing: section lacks the note:\n%s", sec)
+ }
+ // Off stable no major applies, so there is no box and no note.
+ fx.setChannel(heldHost, "beta")
+ if sec := section(); strings.Contains(sec, note) || strings.Contains(sec, `name="major"`) {
+ t.Fatalf("off stable:\n%s", sec)
+ }
+}
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
index 04847d79c..e4f1eaee9 100644
--- a/internal/console/templates/app_instance.html
+++ b/internal/console/templates/app_instance.html
@@ -45,7 +45,7 @@
<div>{{if .AutoLive}}<label><input type="checkbox" name="auto" value="1"{{if eq .UpdatesWord "auto"}} checked{{end}}> {{$.UpdatesText "settings.page.auto-label"}}</label><input type="hidden" name="was_auto" value="{{if eq .UpdatesWord "auto"}}1{{else}}0{{end}}">{{else}}{{$.UpdatesText "settings.page.auto-label"}}: <code>{{.UpdatesWord}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>
<div>{{$.UpdatesText "settings.page.channel-label"}}: <code>{{.Channel}}</code>{{if .KeptChannel}} <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}
<div class="small">{{$.UpdatesText "settings.page.channel-hint"}}</div></div>
- {{if .ShowMajor}}<div>{{if .MajorLive}}<label>{{$.UpdatesText "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{.MajorInput}}"></label><input type="hidden" name="was_major" value="{{.MajorInput}}">{{else}}{{$.UpdatesText "settings.page.major-label"}}: <code>{{.MajorShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>{{end}}
+ {{if .ShowMajor}}<div>{{if .MajorLive}}<label>{{$.UpdatesText "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{.MajorInput}}"></label><input type="hidden" name="was_major" value="{{.MajorInput}}">{{with .MajorInherited}} <span class="small">{{.}}</span>{{end}}{{else}}{{$.UpdatesText "settings.page.major-label"}}: <code>{{.MajorShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>{{end}}
<div>{{if .PinLive}}<label>{{$.UpdatesText "settings.page.pin-label"}} <input type="text" name="pin" value="{{.PinInput}}"></label><input type="hidden" name="was_pin" value="{{.PinInput}}">{{else}}{{$.UpdatesText "settings.page.pin-label"}}: <code>{{.PinShown}}</code> <span class="small">{{$.UpdatesText "settings.page.kept"}}</span>{{end}}</div>
{{if .HasForm}}<button type="submit" class="btn-sm">{{$.UpdatesText "settings.page.save"}}</button>
</form>
diff --git a/internal/console/updatesjoin_test.go b/internal/console/updatesjoin_test.go
index 7e00dd6e9..40920d438 100644
--- a/internal/console/updatesjoin_test.go
+++ b/internal/console/updatesjoin_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"testing"
"time"
@@ -24,7 +25,7 @@ func TestUpdatesJoinCarriesTheReason(t *testing.T) {
two := 2
j := joinBoxes([]status.Box{updatesBox("ie-1", true,
map[string]status.HostUpdates{"a.shop.example.com": {Desired: "v1", Reason: "major", Detail: "v2", Baking: "v4", Major: &two,
- Pending: 7, Completed: 6, CompletedFailed: true}},
+ Pending: 7, Completed: 6, CompletedFailed: true, Account: "bab", App: "shop"}},
map[string]string{"a.shop.example.com": "v1"})}, time.Now())
u := j.update("a.shop.example.com", "bab", "shop")
if u == nil || !u.Enforced || u.Desired != "v1" || u.Reason != "major" || u.Detail != "v2" || u.Baking != "v4" ||
@@ -33,6 +34,56 @@ func TestUpdatesJoinCarriesTheReason(t *testing.T) {
}
}
+// enforcedOnly says u is the join's answer for a report it cannot attribute:
+// whether the box enforces settings, and nothing else.
+func enforcedOnly(u *updatesJoinView, enforced bool) bool {
+ return u != nil && *u == updatesJoinView{Enforced: enforced}
+}
+
+// Astra r1 (R18): a report that names no tenant, on a box with no rollout
+// report for the host, may be another tenant's: the hostname may have been
+// retired and given to this one while the snapshot is still fresh. A
+// platform box's report is no exception. The join answers only whether the
+// box enforces settings, so an old box still reads "not enforced".
+func TestATenantlessReportJoinsOnlyWhetherTheBoxEnforces(t *testing.T) {
+ now := time.Now()
+ secret := map[string]status.HostUpdates{"h": {Desired: "v-a-desired", Reason: "pin", Detail: "v-a-pin", Baking: "v-a-baking", Pending: 4, Completed: 3, CompletedFailed: true}}
+ for _, enforce := range []bool{true, false} {
+ if u := joinBoxes([]status.Box{updatesBox("platform", enforce, secret, map[string]string{"h": "v1"})}, now).update("h", "bab", "shop"); !enforcedOnly(u, enforce) {
+ t.Fatalf("platform box (enforces %v): tenantless report joined as %+v", enforce, u)
+ }
+ var j boxJoin
+ j.joinFleetRollouts([]status.Box{updatesBox("fleet", enforce, secret, map[string]string{"h": "v1"})}, now)
+ if u := j.update("h", "bab", "shop"); !enforcedOnly(u, enforce) {
+ t.Fatalf("fleet box (enforces %v): tenantless report joined as %+v", enforce, u)
+ }
+ }
+}
+
+// R18: an entry that names its own tenant needs no rollout report, and its
+// own word wins over the rollout report's.
+func TestAnAttributedReportNeedsNoRolloutReport(t *testing.T) {
+ now := time.Now()
+ b := updatesBox("platform", true, map[string]status.HostUpdates{"h": {Reason: "manual", Detail: "v1", Account: "bab", App: "shop"}}, map[string]string{"h": "v1"})
+ j := joinBoxes([]status.Box{b}, now)
+ if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" || u.Detail != "v1" || !u.Enforced {
+ t.Fatalf("the named tenant = %+v", u)
+ }
+ for _, other := range [][2]string{{"cdf", "shop"}, {"bab", "blog"}} {
+ if u := j.update("h", other[0], other[1]); u != nil {
+ t.Fatalf("%s/%s joined bab/shop's report: %+v", other[0], other[1], u)
+ }
+ }
+ b.Metrics.Rollout = &status.RolloutReport{Hosts: map[string]status.HostRollout{"h": {Account: "cdf", App: "blog", Installed: "v1"}}}
+ j = joinBoxes([]status.Box{b}, now)
+ if u := j.update("h", "cdf", "blog"); u != nil {
+ t.Fatalf("the rollout report's tenant outranked the entry's own: %+v", u)
+ }
+ if u := j.update("h", "bab", "shop"); u == nil || u.Reason != "manual" {
+ t.Fatalf("the entry's own tenant = %+v", u)
+ }
+}
+
// Review Focus 3: a box's report about a host never surfaces on another
// tenant's list. The box's own rollout report names the host's tenant.
func TestUpdatesJoinRefusesAnotherTenantsReport(t *testing.T) {
@@ -60,13 +111,14 @@ func TestAnOldBoxIsNotEnforced(t *testing.T) {
}
// A fleet box's report names no tenant unless its rollout report does, and a
-// customer's box is not trusted to speak for a host on its own say-so.
+// customer's box is not trusted to speak for a host on its own say-so: all
+// that joins is whether the box enforces settings (R18).
func TestAFleetBoxReportWithoutATenantIsRefused(t *testing.T) {
now := time.Now()
fleet := updatesBox("fleet", true, map[string]status.HostUpdates{"h": {Reason: "manual"}}, map[string]string{"h": "v1"})
var j boxJoin
j.joinFleetRollouts([]status.Box{fleet}, now)
- if u := j.update("h", "bab", "shop"); u != nil {
+ if u := j.update("h", "bab", "shop"); !enforcedOnly(u, true) {
t.Fatalf("a tenantless fleet report was joined: %+v", u)
}
// With the tenant from its own rollout report it is this tenant's, and
@@ -203,3 +255,49 @@ func TestAPIInstanceListSettingsDefaultFollowsThePolicy(t *testing.T) {
t.Fatalf("a manual app default read as default: %s", out)
}
}
+
+// Astra r1 (R18), through the list door: box1 (a platform box with no
+// rollout report, as with CARLOS_SWAP off) still reports the update state
+// it last had for acme under its previous tenant. The hostname now belongs
+// to bab/titogo. The list carries whether the box enforces settings and
+// nothing of the report, and the CLI's REASON cell is empty.
+func TestAReassignedHostNeverListsThePreviousTenantsReport(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-updates-reassigned")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated {
+ t.Fatalf("create = %d %s", code, out)
+ }
+ box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
+ box.Metrics = &status.BoxMetrics{
+ UpdateSettings: status.UpdateSettingsProtocol,
+ RouteVersions: map[string]string{"acme.go.tito.io": "v1"},
+ Updates: map[string]status.HostUpdates{
+ "acme.go.tito.io": {Desired: "v-prev-desired", Reason: "pin", Detail: "v-prev-pin", Baking: "v-prev-baking", Pending: 9, Completed: 8},
+ },
+ }
+ if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodGet, u, token, "")
+ if code != http.StatusOK {
+ t.Fatalf("list = %d %s", code, out)
+ }
+ for _, leak := range []string{"v-prev-desired", "v-prev-pin", "v-prev-baking", `"reason":"pin"`, `"pending"`, `"completed"`} {
+ if strings.Contains(out, leak) {
+ t.Errorf("the list leaked %s: %s", leak, out)
+ }
+ }
+ var l struct {
+ Instances []struct {
+ Host string `json:"host"`
+ UpdateReport *updatesJoinView `json:"update_report"`
+ } `json:"instances"`
+ }
+ if err := json.Unmarshal([]byte(out), &l); err != nil || len(l.Instances) != 1 {
+ t.Fatalf("decode %v: %s", err, out)
+ }
+ if r := l.Instances[0].UpdateReport; !enforcedOnly(r, true) {
+ t.Fatalf("acme update_report = %+v, want enforced and nothing else", r)
+ }
+}
diff --git a/internal/host/updatesreport.go b/internal/host/updatesreport.go
index 1dfec7499..6a11276e0 100644
--- a/internal/host/updatesreport.go
+++ b/internal/host/updatesreport.go
@@ -40,7 +40,7 @@ func fillUpdateProbes(p *status.Probes, reg *registry.Registry, optDir string) {
}
// updatesReport is root's last decision per host with settings or a decision,
-// for the console's list and instance page. The column is read as root reads
+// for the console's list and instance page, each naming the host's tenant. The column is read as root reads
// it (ParseUpdateSettingsLenient): a Control name a newer console added must
// not blank the report. A column that does not parse even then still reports
// root's decision (its reason is unknown).
@@ -60,7 +60,10 @@ func updatesReport(reg *registry.Registry, optDir string) map[string]status.Host
continue
}
bs, _ := release.ParseBuildStateCopy(rt.BuildState)
- h := status.HostUpdates{}
+ // The tenant is the route's, by the rollout report's rule
+ // (HostRollout.Account and .App), so the console can attribute the
+ // report without a rollout report (Astra r1, R18).
+ h := status.HostUpdates{Account: release.RouteAccount(rt), App: rt.App}
if perr == nil {
h.Major = u.Major
}
diff --git a/internal/host/updatesreport_test.go b/internal/host/updatesreport_test.go
index 81b3795c3..049ad6f8e 100644
--- a/internal/host/updatesreport_test.go
+++ b/internal/host/updatesreport_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"testing"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
)
@@ -28,6 +29,29 @@ func TestTheReportCarriesRootsDecision(t *testing.T) {
}
}
+// Astra r1 (R18): each entry names the instance's tenant from the box's own
+// route, as the rollout report does (release.RouteAccount), so the console
+// never needs the rollout report to tell whose report it is.
+func TestTheReportNamesTheTenant(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ if err := reg.Upsert(registry.Route{Host: "b.example.com", Kind: "instance", Account: "bab", App: "shop", Hibernate: true, Socket: "/run/carlos/b.sock"}); err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: h, Desired: "v1", Reason: "manual"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got := updatesReport(reg, b.optDir)
+ if u := got["b.example.com"]; u.Account != "bab" || u.App != "shop" || u.Reason != "manual" {
+ t.Fatalf("b = %+v; want tenant bab/shop", u)
+ }
+ // A route with no account of its own is the platform's, as everywhere.
+ if u := got["a.example.com"]; u.Account != release.OpsAccount || u.App != "km" {
+ t.Fatalf("a = %+v; want tenant %s/km", u, release.OpsAccount)
+ }
+}
+
func TestEveryBoxOfThisBinaryReportsTheProtocol(t *testing.T) {
if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol {
t.Fatalf("this binary reports %d", m)
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index a46b992ac..011e17935 100644
--- a/internal/status/contract_test.go
+++ b/internal/status/contract_test.go
@@ -107,8 +107,10 @@ var allowedBoxKeys = map[string][]string{
"metrics.build_states.*": {"account", "app", "builds", "complete"},
// updates (settings spec ยง4.3) is host -> root's last decision for that
// host, in the private block only; the values are pinned to the field level.
+ // account and app are the host's tenant (Astra r1, R18), as build_states
+ // carries it.
"metrics.updates": {wildcardAnyKey},
- "metrics.updates.*": {"desired", "reason", "detail", "baking", "major", "pending", "completed", "completed_failed"},
+ "metrics.updates.*": {"desired", "reason", "detail", "baking", "major", "pending", "completed", "completed_failed", "account", "app"},
}
// allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as
@@ -364,7 +366,7 @@ func fixtureProbes() status.Probes {
// allowlist walk too.
UpdateSettings: status.UpdateSettingsProtocol,
Updates: map[string]status.HostUpdates{
- "contract-decided.internal": {Desired: "v2", Reason: "major", Detail: "v2", Baking: "v3", Major: &contractMajor, Pending: 4, Completed: 3, CompletedFailed: true},
+ "contract-decided.internal": {Desired: "v2", Reason: "major", Detail: "v2", Baking: "v3", Major: &contractMajor, Pending: 4, Completed: 3, CompletedFailed: true, Account: "bab", App: "km"},
},
ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)",
MemFrac: &memFrac,
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 86e21d544..83e981be8 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -211,6 +211,13 @@ type HostUpdates struct {
Pending uint64 `json:"pending,omitempty"`
Completed uint64 `json:"completed,omitempty"`
CompletedFailed bool `json:"completed_failed,omitempty"`
+ // Account and App are the instance's tenant as the box's route names it
+ // (HostRollout.Account and .App's rule), so the console can join the
+ // report to a tenant without the rollout report (Astra r1, R18). Absent
+ // from a box that predates them: the console then joins only whether the
+ // box enforces settings, nothing the report says about the instance.
+ Account string `json:"account,omitempty"`
+ App string `json:"app,omitempty"`
}
// RowLayout is one instance-kind route's layout, as the box's layout pass
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index ad9400103..b5e0f2bc9 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -1694,7 +1694,8 @@ func TestCollectBoxCarriesTheUpdateSettingsProtocolAndDecisions(t *testing.T) {
two := 2
ups := map[string]status.HostUpdates{
- "decided-secret.example.com": {Desired: "v-secret-desired", Reason: "major", Detail: "v1", Baking: "v-secret-baking", Major: &two, Pending: 3, Completed: 2, CompletedFailed: true},
+ "decided-secret.example.com": {Desired: "v-secret-desired", Reason: "major", Detail: "v1", Baking: "v-secret-baking", Major: &two, Pending: 3, Completed: 2, CompletedFailed: true,
+ Account: "acct-secret-tenant", App: "app-secret-tenant"},
}
b := status.CollectBox(routes, status.Probes{MachineID: "m", Updates: ups}, time.Now())
if b.Metrics == nil || !reflect.DeepEqual(b.Metrics.Updates, ups) {
@@ -1714,7 +1715,7 @@ func TestCollectBoxCarriesTheUpdateSettingsProtocolAndDecisions(t *testing.T) {
t.Fatal(err)
}
fj, _ := json.Marshal(f)
- for _, needle := range []string{"decided-secret.example.com", "v-secret-desired", "v-secret-baking", `"updates"`, "update_settings"} {
+ for _, needle := range []string{"decided-secret.example.com", "v-secret-desired", "v-secret-baking", "acct-secret-tenant", "app-secret-tenant", `"updates"`, "update_settings"} {
if strings.Contains(string(fj), needle) {
t.Errorf("fleet.json leaked %q: %s", needle, fj)
}
diff --git a/internal/updatewords/export_test.go b/internal/updatewords/export_test.go
index 5e39f58be..6c7caaea5 100644
--- a/internal/updatewords/export_test.go
+++ b/internal/updatewords/export_test.go
@@ -19,6 +19,7 @@ func sectionFor(id string) string {
strings.HasPrefix(id, "settings.cli.update-"),
id == "settings.cli.direct-mode",
id == "settings.cli.bad-major-flag",
+ id == "settings.cli.flag-account-id",
id == "settings.cli.none":
return "CLI: carlos instances set and update"
case id == "settings.cli.not-enforced-reason":
@@ -47,6 +48,20 @@ func TestEverySurfaceDraftHasASection(t *testing.T) {
}
}
+// Astra r1 (R21): the docs lead-in and the shared --account-id help land in
+// the sections Paul reviews them under.
+func TestTheR21DraftsLandInTheirSections(t *testing.T) {
+ for id, want := range map[string]string{
+ "settings.cli.docs-apps-set-lead": "Docs and roll notes",
+ "settings.cli.flag-account-id": "CLI: carlos instances set and update",
+ "settings.page.major-inherited": "Console: instance page, Updates section",
+ } {
+ if got := sectionFor(id); got != want {
+ t.Errorf("%s is in %q, want %q", id, got, want)
+ }
+ }
+}
+
// TestExportDrafts writes every plan-5 draft to $UPDATEWORDS_EXPORT in JSON
// format, organized by section for review. It does nothing unless the
// variable is set.
diff --git a/internal/updatewords/words_docs_test.go b/internal/updatewords/words_docs_test.go
index bf5752966..74c2d3d2c 100644
--- a/internal/updatewords/words_docs_test.go
+++ b/internal/updatewords/words_docs_test.go
@@ -16,6 +16,7 @@ func TestDocsContainDrafts(t *testing.T) {
}{
{"settings.cli.docs-instances-set", "../../docs/site/cli.md"},
{"settings.cli.docs-instances-update", "../../docs/site/cli.md"},
+ {"settings.cli.docs-apps-set-lead", "../../docs/site/cli.md"},
{"settings.cli.docs-apps-set", "../../docs/site/cli.md"},
{"settings.cli.roll-note", "../../README.md"},
}
diff --git a/internal/updatewords/words_surfaces.go b/internal/updatewords/words_surfaces.go
index 717c249ee..f134b52af 100644
--- a/internal/updatewords/words_surfaces.go
+++ b/internal/updatewords/words_surfaces.go
@@ -51,6 +51,7 @@ func init() {
Draft{ID: "settings.page.channel-label", Text: "Channel", Context: "The channel the instance follows, shown read-only in the Updates section."},
Draft{ID: "settings.page.channel-hint", Text: "Change it with carlos instances set --channel. The app's pipeline rules apply.", Context: "Small note under the channel. The console page has no channel control, because a move may need passkeys and approvals."},
Draft{ID: "settings.page.major-label", Text: "Stay on major version", Context: "Number box, empty for no hold. Only shown for instances on stable."},
+ Draft{ID: "settings.page.major-inherited", Text: "The app holds it at major %d. Leave this empty to keep the app's hold.", Context: "Small note beside the major version box when the instance has no hold of its own and the app's default holds it. %d is the major number."},
Draft{ID: "settings.page.pin-label", Text: "Pin to build", Context: "Text box: a build name, empty for no pin. A pinned instance runs exactly that build."},
Draft{ID: "settings.page.save", Text: "Save", Context: "Button under the settings."},
Draft{ID: "settings.page.update-now", Text: "Update now", Context: "Button. Asks this instance's box to install the channel's newest build it considers ready."},
@@ -69,10 +70,12 @@ func init() {
Draft{ID: "settings.cli.set-flag-major", Text: "stay on this major version of stable; empty clears", Context: "Help text for carlos instances set --major."},
Draft{ID: "settings.cli.set-flag-pin", Text: "run exactly this build; empty clears", Context: "Help text for carlos instances set --pin."},
Draft{ID: "settings.cli.update-flag-fast-track", Text: "skip the safety delay (account owners only)", Context: "Help text for carlos instances update --fast-track."},
+ Draft{ID: "settings.cli.flag-account-id", Text: "account sqid, matched as a sqid ONLY; supersedes --account", Context: "Help text for --account-id on carlos instances set and carlos instances update."},
// docs
Draft{ID: "settings.cli.docs-instances-set", Text: "Sets how one instance takes new builds: auto or manual updates, the channel, a major version to stay on (stable only), or an exact build to pin. An empty value clears a setting, so the app's default applies again.", Context: "The CLI docs page's carlos instances set section."},
Draft{ID: "settings.cli.docs-instances-update", Text: "Asks the instance's box to install the newest build it considers ready, even with manual updates. The box chooses the build, and it runs on the next start. --fast-track skips the safety delay; only account owners can use it.", Context: "The CLI docs page's carlos instances update section."},
- Draft{ID: "settings.cli.docs-apps-set", Text: "--instance-control names the settings an instance may change itself once instances can act for themselves; --default-updates, --default-channel and --default-major apply wherever an instance has not chosen.", Context: "Added to the CLI docs page's carlos apps set section."},
- Draft{ID: "settings.cli.roll-note", Text: "**Instance update settings: the roll order.** Roll every box to the release that applies update settings before anyone turns on manual updates, a major hold or a pin: the console refuses those until every box reports it can protect the builds they name from pruning, and lists the boxes it is waiting on. Before rolling any box back below that release, turn retention off for the deployment (`carlos releases retention --off` for each app that has one), because an older housekeeper prunes builds a pin or a held instance still needs. After such a rollback the console shows *settings not enforced* for instances on the older boxes: they follow their channel until the box is rolled forward again.", Context: "The operator note added to README.md's release notes section."},
+ Draft{ID: "settings.cli.docs-apps-set-lead", Text: "`carlos apps set` also controls the update settings an instance may change:", Context: "Lead-in sentence before the carlos apps set update-policy example on the CLI docs page."},
+ Draft{ID: "settings.cli.docs-apps-set", Text: "--instance-control names the settings an instance may change itself once instances can act for themselves. --default-updates and --default-major apply wherever an instance has not chosen; --default-channel is the channel new instances are created on.", Context: "Added to the CLI docs page's carlos apps set section."},
+ Draft{ID: "settings.cli.roll-note", Text: "**Instance update settings: the roll order.** Roll every box to the release that applies update settings before anyone turns on manual updates, a major hold or a pin: the console refuses those until every box reports it can protect the builds they name from pruning, and says how many boxes it is waiting on. Before rolling any box back below that release, turn retention off for the deployment (`carlos releases retention --off` for each app that has one), because an older housekeeper prunes builds a pin or a held instance still needs. After such a rollback the console shows *settings not enforced* for instances on the older boxes: they follow their channel until the box is rolled forward again.", Context: "The operator note added to README.md's release notes section."},
)
}