| 1 | // The D1 rollback (spec 2026-10-06-console-release-defences-design.md §2.3): |
| 2 | // what adoptHost does when a named unit's restart reports that the new build |
| 3 | // did not come up, as opposed to systemctl itself failing. |
| 4 | package release |
| 5 | |
| 6 | import ( |
| 7 | "context" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | ) |
| 15 | |
| 16 | // UnitNotUpError is a named unit that was restarted onto a new build and did |
| 17 | // not settle: it failed, restarted again, or was not ready by the deadline. |
| 18 | // The host package's settle check returns it (internal/host/unitsettle.go). |
| 19 | type UnitNotUpError struct { |
| 20 | Unit string |
| 21 | Reason string |
| 22 | Journal string // the unit's last journal lines, for the rollback line and the alert |
| 23 | } |
| 24 | |
| 25 | func (e *UnitNotUpError) Error() string { |
| 26 | return fmt.Sprintf("%s did not come up: %s", e.Unit, e.Reason) |
| 27 | } |
| 28 | |
| 29 | // upstream is a route's effective upstream: Socket wins over Addr, as at the |
| 30 | // edge (registry.Route.Socket's doc). |
| 31 | func upstream(rt registry.Route) string { |
| 32 | if rt.Socket != "" { |
| 33 | return rt.Socket |
| 34 | } |
| 35 | return rt.Addr |
| 36 | } |
| 37 | |
| 38 | // processGroup is every OTHER route served by bound's process: unstamped, |
| 39 | // same account and app, same non-empty upstream, and the SAME CHANNEL. A |
| 40 | // bound route with no upstream is a group of one. |
| 41 | // |
| 42 | // The channel matters because every group record is written with bound's |
| 43 | // promotion (its pointer's PromotedAt). A same-upstream route on another |
| 44 | // channel follows a separate promotion, so a record stamped with bound's |
| 45 | // would never block its own pointer: its next pass would re-adopt the |
| 46 | // rejected build anyway. It is left out, untouched and unrefused. |
| 47 | func processGroup(reg *registry.Registry, bound registry.Route) []registry.Route { |
| 48 | up := upstream(bound) |
| 49 | if up == "" { |
| 50 | return nil |
| 51 | } |
| 52 | all, err := reg.List() |
| 53 | if err != nil { |
| 54 | fmt.Printf(" %s: listing routes for the rollback group: %v (rolling back this host only)\n", bound.Host, err) |
| 55 | return nil |
| 56 | } |
| 57 | var out []registry.Route |
| 58 | for _, r := range all { |
| 59 | if r.Host == bound.Host || r.Unit != "" || r.App != bound.App || |
| 60 | RouteAccount(r) != RouteAccount(bound) || upstream(r) != up || |
| 61 | routeChannel(r) != routeChannel(bound) { |
| 62 | continue |
| 63 | } |
| 64 | out = append(out, r) |
| 65 | } |
| 66 | return out |
| 67 | } |
| 68 | |
| 69 | // unitServed reports whether rt's process is a unit an operator named: rt |
| 70 | // names one itself, or rt is in the processGroup of a route that does. A |
| 71 | // registry that cannot be listed answers true, so a RootRestartsUnits pass |
| 72 | // hands the route to root rather than stamp a build the unit may not run. |
| 73 | func unitServed(reg *registry.Registry, rt registry.Route) bool { |
| 74 | if rt.Unit != "" { |
| 75 | return true |
| 76 | } |
| 77 | up := upstream(rt) |
| 78 | if up == "" { |
| 79 | return false |
| 80 | } |
| 81 | all, err := reg.List() |
| 82 | if err != nil { |
| 83 | return true |
| 84 | } |
| 85 | for _, r := range all { |
| 86 | if r.Host != rt.Host && r.Unit != "" && r.App == rt.App && |
| 87 | RouteAccount(r) == RouteAccount(rt) && upstream(r) == up && |
| 88 | routeChannel(r) == routeChannel(rt) { |
| 89 | return true |
| 90 | } |
| 91 | } |
| 92 | return false |
| 93 | } |
| 94 | |
| 95 | // rollbackGroup puts every group route whose link names the rejected version |
| 96 | // onto restored (what bound now runs), stamps it to match, and returns bound |
| 97 | // and every group host: the hosts writeGroupRefusals refuses. restored "" |
| 98 | // (bound's own rollback failed, or it had nothing to go back to) repoints |
| 99 | // nothing: a sibling must not claim a recovery the bound route never made. |
| 100 | // |
| 101 | // A sibling is stamped with bound's PrevVersion, not its own. Earlier in this |
| 102 | // pass it was stamped rejected over restored, so its own PrevVersion now |
| 103 | // names restored; bound, never stamped, still holds the history both share. |
| 104 | // |
| 105 | // workFor is asked ONCE per sibling it would write, and the guard it returns |
| 106 | // runs both of that sibling's writes (hostwrite): the repoint and the stamp |
| 107 | // are checked against one selection, so an installation that changes between |
| 108 | // them refuses the stamp instead of handing it to the successor. A sibling |
| 109 | // whose work cannot be had, or whose selection has gone stale, is left alone |
| 110 | // and still refused. |
| 111 | func rollbackGroup(reg *registry.Registry, optDir string, bound registry.Route, rejected, restored string, workFor func(host string) (func(func() error) error, error)) []string { |
| 112 | hosts := []string{bound.Host} |
| 113 | for _, g := range processGroup(reg, bound) { |
| 114 | hosts = append(hosts, g.Host) |
| 115 | v, err := LiveVersion(optDir, g.Host) |
| 116 | if err != nil || v != rejected || restored == "" { |
| 117 | continue |
| 118 | } |
| 119 | with, err := workFor(g.Host) |
| 120 | if err != nil { |
| 121 | fmt.Printf(" %s: not rolled back with its process: release work: %v\n", g.Host, err) |
| 122 | continue |
| 123 | } |
| 124 | if err := with(func() error { return PointLive(optDir, g.Host, RouteAccount(g), restored, g.App) }); err != nil { |
| 125 | fmt.Printf(" %s: could not roll the symlink back to %s with its process: %v\n", g.Host, restored, err) |
| 126 | continue |
| 127 | } |
| 128 | if err := with(func() error { return reg.SetVersions(g.Host, restored, bound.PrevVersion) }); err != nil { |
| 129 | fmt.Printf(" %s: rolled back to %s but recording the version failed: %v\n", g.Host, restored, err) |
| 130 | } |
| 131 | } |
| 132 | return hosts |
| 133 | } |
| 134 | |
| 135 | // recovery is how a D1 rollback ended, recorded on every refusal it writes. |
| 136 | type recovery struct { |
| 137 | restored string // the version bound's link was put back on; "" if none |
| 138 | recovered bool |
| 139 | err string // why not, when !recovered |
| 140 | } |
| 141 | |
| 142 | // writeGroupRefusals writes one refusal per host, AFTER the rollback restart, |
| 143 | // so each record carries the recovery's real outcome (spec §2.6). |
| 144 | func writeGroupRefusals(cfg passConfig, hosts []string, app string, ptr *ChannelPointer, |
| 145 | rejected, sha, platform string, nu *UnitNotUpError, rec recovery, now time.Time) { |
| 146 | if cfg.bakeDir == "" && !cfg.requireRootHistory { |
| 147 | return |
| 148 | } |
| 149 | reason := nu.Reason |
| 150 | if j := strings.TrimSpace(nu.Journal); j != "" { |
| 151 | lines := strings.Split(j, "\n") |
| 152 | reason += " — " + lines[len(lines)-1] |
| 153 | } |
| 154 | for _, h := range hosts { |
| 155 | r := Refusal{Host: h, App: app, Version: rejected, SHA256: sha, Platform: platform, |
| 156 | PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(), Reason: reason, |
| 157 | RestoredVersion: rec.restored, Recovered: rec.recovered, RecoveryError: rec.err} |
| 158 | rt := registry.Route{Host: h, App: app} |
| 159 | if cfg.requireRootHistory { |
| 160 | current, err := cfg.route(h) |
| 161 | if err != nil || current == nil || current.App != app { |
| 162 | cfg.historyError(os.ErrPermission) |
| 163 | continue |
| 164 | } |
| 165 | rt = *current |
| 166 | } |
| 167 | if err := cfg.recordRefusal(context.Background(), rt, r); err != nil { |
| 168 | cfg.historyError(err) |
| 169 | fmt.Printf(" %s: writing refusal record: %v\n", h, err) |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func indent(s, pre string) string { |
| 175 | return pre + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n"+pre) |
| 176 | } |
| 177 | |