// The D1 rollback (spec 2026-10-06-console-release-defences-design.md §2.3): // what adoptHost does when a named unit's restart reports that the new build // did not come up, as opposed to systemctl itself failing. package release import ( "context" "fmt" "os" "strings" "time" "github.com/carlosframework/platform/internal/registry" ) // UnitNotUpError is a named unit that was restarted onto a new build and did // not settle: it failed, restarted again, or was not ready by the deadline. // The host package's settle check returns it (internal/host/unitsettle.go). type UnitNotUpError struct { Unit string Reason string Journal string // the unit's last journal lines, for the rollback line and the alert } func (e *UnitNotUpError) Error() string { return fmt.Sprintf("%s did not come up: %s", e.Unit, e.Reason) } // upstream is a route's effective upstream: Socket wins over Addr, as at the // edge (registry.Route.Socket's doc). func upstream(rt registry.Route) string { if rt.Socket != "" { return rt.Socket } return rt.Addr } // processGroup is every OTHER route served by bound's process: unstamped, // same account and app, same non-empty upstream, and the SAME CHANNEL. A // bound route with no upstream is a group of one. // // The channel matters because every group record is written with bound's // promotion (its pointer's PromotedAt). A same-upstream route on another // channel follows a separate promotion, so a record stamped with bound's // would never block its own pointer: its next pass would re-adopt the // rejected build anyway. It is left out, untouched and unrefused. func processGroup(reg *registry.Registry, bound registry.Route) []registry.Route { up := upstream(bound) if up == "" { return nil } all, err := reg.List() if err != nil { fmt.Printf(" %s: listing routes for the rollback group: %v (rolling back this host only)\n", bound.Host, err) return nil } var out []registry.Route for _, r := range all { if r.Host == bound.Host || r.Unit != "" || r.App != bound.App || RouteAccount(r) != RouteAccount(bound) || upstream(r) != up || routeChannel(r) != routeChannel(bound) { continue } out = append(out, r) } return out } // unitServed reports whether rt's process is a unit an operator named: rt // names one itself, or rt is in the processGroup of a route that does. A // registry that cannot be listed answers true, so a RootRestartsUnits pass // hands the route to root rather than stamp a build the unit may not run. func unitServed(reg *registry.Registry, rt registry.Route) bool { if rt.Unit != "" { return true } up := upstream(rt) if up == "" { return false } all, err := reg.List() if err != nil { return true } for _, r := range all { if r.Host != rt.Host && r.Unit != "" && r.App == rt.App && RouteAccount(r) == RouteAccount(rt) && upstream(r) == up && routeChannel(r) == routeChannel(rt) { return true } } return false } // rollbackGroup puts every group route whose link names the rejected version // onto restored (what bound now runs), stamps it to match, and returns bound // and every group host: the hosts writeGroupRefusals refuses. restored "" // (bound's own rollback failed, or it had nothing to go back to) repoints // nothing: a sibling must not claim a recovery the bound route never made. // // A sibling is stamped with bound's PrevVersion, not its own. Earlier in this // pass it was stamped rejected over restored, so its own PrevVersion now // names restored; bound, never stamped, still holds the history both share. // // workFor is asked ONCE per sibling it would write, and the guard it returns // runs both of that sibling's writes (hostwrite): the repoint and the stamp // are checked against one selection, so an installation that changes between // them refuses the stamp instead of handing it to the successor. A sibling // whose work cannot be had, or whose selection has gone stale, is left alone // and still refused. func rollbackGroup(reg *registry.Registry, optDir string, bound registry.Route, rejected, restored string, workFor func(host string) (func(func() error) error, error)) []string { hosts := []string{bound.Host} for _, g := range processGroup(reg, bound) { hosts = append(hosts, g.Host) v, err := LiveVersion(optDir, g.Host) if err != nil || v != rejected || restored == "" { continue } with, err := workFor(g.Host) if err != nil { fmt.Printf(" %s: not rolled back with its process: release work: %v\n", g.Host, err) continue } if err := with(func() error { return PointLive(optDir, g.Host, RouteAccount(g), restored, g.App) }); err != nil { fmt.Printf(" %s: could not roll the symlink back to %s with its process: %v\n", g.Host, restored, err) continue } if err := with(func() error { return reg.SetVersions(g.Host, restored, bound.PrevVersion) }); err != nil { fmt.Printf(" %s: rolled back to %s but recording the version failed: %v\n", g.Host, restored, err) } } return hosts } // recovery is how a D1 rollback ended, recorded on every refusal it writes. type recovery struct { restored string // the version bound's link was put back on; "" if none recovered bool err string // why not, when !recovered } // writeGroupRefusals writes one refusal per host, AFTER the rollback restart, // so each record carries the recovery's real outcome (spec §2.6). func writeGroupRefusals(cfg passConfig, hosts []string, app string, ptr *ChannelPointer, rejected, sha, platform string, nu *UnitNotUpError, rec recovery, now time.Time) { if cfg.bakeDir == "" && !cfg.requireRootHistory { return } reason := nu.Reason if j := strings.TrimSpace(nu.Journal); j != "" { lines := strings.Split(j, "\n") reason += " — " + lines[len(lines)-1] } for _, h := range hosts { r := Refusal{Host: h, App: app, Version: rejected, SHA256: sha, Platform: platform, PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(), Reason: reason, RestoredVersion: rec.restored, Recovered: rec.recovered, RecoveryError: rec.err} rt := registry.Route{Host: h, App: app} if cfg.requireRootHistory { current, err := cfg.route(h) if err != nil || current == nil || current.App != app { cfg.historyError(os.ErrPermission) continue } rt = *current } if err := cfg.recordRefusal(context.Background(), rt, r); err != nil { cfg.historyError(err) fmt.Printf(" %s: writing refusal record: %v\n", h, err) } } } func indent(s, pre string) string { return pre + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n"+pre) }