carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
4package release
5
6import (
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).
19type 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
25func (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).
31func 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.
47func 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.
73func 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.
111func 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.
136type 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).
144func 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
174func indent(s, pre string) string {
175 return pre + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n"+pre)
176}
177