| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "sort" |
| 6 | "strings" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | "github.com/carlosframework/platform/internal/status" |
| 11 | ) |
| 12 | |
| 13 | // layoutDowngradeBlocked is design §8.2's rollback rule at the console: a |
| 14 | // channel pointer must not move below release.LayoutFloor while any box |
| 15 | // could still hold a nested row. The box-side guard (host/systemapply.go) |
| 16 | // refuses the install too, but only on the box that runs it; this one |
| 17 | // stops the pointer moving at all, so no box has to refuse. |
| 18 | // |
| 19 | // It judges OBSERVED state, never the intent: a revert refused on a box |
| 20 | // leaves a nested row under a flat intent. A box that has not reported in |
| 21 | // time, or whose snapshot carries no layout at all, is unknown and blocks, |
| 22 | // because the cost of guessing is a database hidden under a mount. The |
| 23 | // empty string means the move is allowed. |
| 24 | func (s *Server) layoutDowngradeBlocked(ctx context.Context, target string) string { |
| 25 | if !release.PreLayout(target) { |
| 26 | return "" |
| 27 | } |
| 28 | // Every fleet's boxes, the union carlos system status reads. Strict: |
| 29 | // a fleet whose heartbeats cannot be read is a fleet whose rows are |
| 30 | // unknown, and unknown blocks. |
| 31 | boxes, _, err := s.systemBoxes(ctx, true) |
| 32 | if err != nil { |
| 33 | return target + " predates the instance layout and the fleet's layout is unknown: could not read the boxes' snapshots: " + err.Error() |
| 34 | } |
| 35 | now := time.Now() |
| 36 | var reasons []string |
| 37 | for _, b := range boxes { |
| 38 | switch { |
| 39 | case status.BoxStale(b, now, status.SnapshotPeriod): |
| 40 | reasons = append(reasons, b.Label+" is stale") |
| 41 | case b.Metrics == nil || b.Metrics.Layout == nil: |
| 42 | // A box whose running release parses and is below the floor |
| 43 | // cannot have run the layout pass (the pass and its report |
| 44 | // arrived together, in the floor release), so its silence is |
| 45 | // flat: today's fleet, on v0.21.0 to v0.24.2, keeps its |
| 46 | // rollback. Silence at or above the floor, or from a box whose |
| 47 | // release is a sha or "dev" (which could be a build with the |
| 48 | // pass), is unknown and blocks. A box at or above the floor |
| 49 | // with no activator publishes a report of its own (Mode |
| 50 | // "unmanaged", host/layoutleg.go), so it is not silent here. |
| 51 | if b.Metrics != nil && release.BelowFloor(b.Metrics.Release, release.LayoutFloor) { |
| 52 | continue |
| 53 | } |
| 54 | reasons = append(reasons, b.Label+" reports no layout field") |
| 55 | default: |
| 56 | for host, row := range b.Metrics.Layout.Rows { |
| 57 | switch row.Layout { |
| 58 | case "nested", "fenced", "conflict": |
| 59 | reasons = append(reasons, b.Label+": "+host+" is "+row.Layout) |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | if len(reasons) == 0 { |
| 65 | return "" |
| 66 | } |
| 67 | sort.Strings(reasons) |
| 68 | return target + " predates the instance layout and the fleet is not flat: " + strings.Join(reasons, "; ") + |
| 69 | ". Run `carlos system layout revert`, wait for `carlos status --layout` to show every row flat, then retry" |
| 70 | } |
| 71 | |
| 72 | // layoutMigrateBlocked refuses `layout migrate` while any box in scope |
| 73 | // (every box for "", else the one labelled box) runs a release below |
| 74 | // release.LayoutFloor, runs one this console cannot place and reports no |
| 75 | // layout, or is stale: a nested row minted or moved on such a box would never |
| 76 | // wake, so nothing may migrate before the floor release is cut and rolled. |
| 77 | // A named box that no snapshot knows is refused too: an intent for a box |
| 78 | // nobody can see is one nobody can check. |
| 79 | func (s *Server) layoutMigrateBlocked(ctx context.Context, box string) string { |
| 80 | boxes, _, err := s.systemBoxes(ctx, true) |
| 81 | if err != nil { |
| 82 | return "layout migrate refused: could not read the boxes' snapshots: " + err.Error() |
| 83 | } |
| 84 | now := time.Now() |
| 85 | var reasons []string |
| 86 | seen := false |
| 87 | for _, b := range boxes { |
| 88 | if box != "" && b.Label != box { |
| 89 | continue |
| 90 | } |
| 91 | seen = true |
| 92 | switch { |
| 93 | case status.BoxStale(b, now, status.SnapshotPeriod): |
| 94 | reasons = append(reasons, b.Label+" is stale") |
| 95 | case b.Metrics == nil || b.Metrics.Release == "": |
| 96 | reasons = append(reasons, b.Label+" reports no release") |
| 97 | case release.BelowFloor(b.Metrics.Release, release.LayoutFloor): |
| 98 | reasons = append(reasons, b.Label+" runs "+b.Metrics.Release) |
| 99 | case release.PreLayout(b.Metrics.Release) && b.Metrics.Layout == nil: |
| 100 | // A sha or "dev" (a dev box tracking main) cannot be placed |
| 101 | // against the floor, but a layout report proves the build |
| 102 | // carries the pass, which is what the floor stands for; with |
| 103 | // no report there is no such proof. |
| 104 | reasons = append(reasons, b.Label+" runs "+b.Metrics.Release+", which cannot be placed against v"+release.LayoutFloor.String()+", and reports no layout") |
| 105 | } |
| 106 | } |
| 107 | if box != "" && !seen { |
| 108 | reasons = append(reasons, "box "+box+" has not reported") |
| 109 | } |
| 110 | if len(reasons) == 0 { |
| 111 | return "" |
| 112 | } |
| 113 | sort.Strings(reasons) |
| 114 | return "layout migrate refused: every box in scope must run v" + release.LayoutFloor.String() + " or later and be reporting: " + strings.Join(reasons, "; ") |
| 115 | } |
| 116 | |
| 117 | // layoutRevertBlocked is #132's interlock on the layout door: a revert is |
| 118 | // refused for any box in scope ("" is every box) that has a maintenance |
| 119 | // worker head whose status is active or revoking. A worker's bearer |
| 120 | // credential and its private input live on the box as the agent's uid, |
| 121 | // and a flat tenant shares that uid and the agent's /proc view, so running |
| 122 | // a tenant flat beside a worker hands it both. A detached head holds no |
| 123 | // credential and does not block. |
| 124 | // |
| 125 | // Heads are read through cfg.WorkerHeads. On main the signed WorkerHead |
| 126 | // shape exists (release/workercontrol.go) but no object key, store reader |
| 127 | // or lifecycle writer does yet: #132's codec and store slice landed with |
| 128 | // "no runtime caller". TODO(#132): wire WorkerHeads to the head store once |
| 129 | // its key and reader land, and have the box-side revert pass refuse while |
| 130 | // a durable worker token exists, as #132 also asks. Until then no worker |
| 131 | // is enrolled anywhere (#132, #126), and a nil seam reads as "no heads". |
| 132 | func (s *Server) layoutRevertBlocked(ctx context.Context, box string) string { |
| 133 | if s.cfg.WorkerHeads == nil { |
| 134 | return "" |
| 135 | } |
| 136 | heads, err := s.cfg.WorkerHeads(ctx) |
| 137 | if err != nil { |
| 138 | return "could not read the maintenance worker heads, so the revert is refused: " + err.Error() |
| 139 | } |
| 140 | var reasons []string |
| 141 | for _, h := range heads { |
| 142 | if box != "" && h.Box != box { |
| 143 | continue |
| 144 | } |
| 145 | if h.Status == "active" || h.Status == "revoking" { |
| 146 | reasons = append(reasons, h.Box+" has a maintenance worker ("+h.Status+")") |
| 147 | } |
| 148 | } |
| 149 | if len(reasons) == 0 { |
| 150 | return "" |
| 151 | } |
| 152 | sort.Strings(reasons) |
| 153 | return "layout revert refused: " + strings.Join(reasons, "; ") + ". A flat tenant shares the worker's user and can read its credential; detach the worker first" |
| 154 | } |
| 155 | |