package main import ( "context" "errors" "flag" "fmt" "strings" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // following acts on what the catalogue announces. // // **It holds the stores, not a copy of the decision.** What to do about an upgrade is read when // one arrives, so changing it takes effect on the next upgrade rather than on the next restart of // the control plane. type following struct{ open *stores } // Upgraded sends the machines running a module the version the catalogue now considers current — // or records that they are behind, which is the default and needs no record. // // **Recording is not a second code path.** A machine that is not running what the mesh would send // it is already something the mesh notices and reports; that is what `status` and `push --behind` // are built on. So "record it" is the absence of an action, and the only thing this has to decide // is whether to act. func (f following) Upgraded(ctx context.Context, u link.Upgraded) error { inv := f.open.inventory // The store read first, and an outage there said as one, so the announcement is held and asked // again (novox/hq issue 083). Only here: a push that fails further down is not asked again. decision, err := inv.UpgradeOf(ctx, u.Module) if err != nil { return notNow(err) } on, err := inv.Running(ctx, u.Module) if err != nil { return notNow(err) } if len(on) == 0 { fmt.Printf("%s moved to %s; no machine runs it\n", u.Module, shortCommit(u.Commit)) return nil } if !decision.RollOut { // Named rather than counted, and said even though nothing happens: an upgrade that was // deliberately not rolled out and an upgrade that was never noticed look identical in a // log that only speaks when it acts. fmt.Printf("%s moved to %s; %s %s behind it, and this mesh records upgrades rather than "+ "rolling them out — `push --behind` when you want them\n", u.Module, shortCommit(u.Commit), readableList(on), isAre(len(on))) return nil } if decision.Together { fmt.Printf("%s moved to %s; sending %s together\n", u.Module, shortCommit(u.Commit), readableList(on)) return sendTo(ctx, f.open, on) } // One at a time, and stopping at the first that fails. // // **Stopping is the point.** The machines are done one after another precisely so that a // version that breaks the first one does not reach the rest; carrying on past a failure would // make this the same as sending them together, only slower. fmt.Printf("%s moved to %s; sending %s one at a time\n", u.Module, shortCommit(u.Commit), readableList(on)) for _, node := range on { if err := sendTo(ctx, f.open, []string{node}); err != nil { return fmt.Errorf("%s did not take %s, so the machines after it were left alone: %w", node, u.Module, err) } } return nil } // readableList names machines the way a sentence does, because this is read by a person deciding // whether an upgrade went where they expected. func readableList(names []string) string { switch len(names) { case 0: return "nothing" case 1: return names[0] case 2: return names[0] + " and " + names[1] } return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] } func shortCommit(commit string) string { if len(commit) > 8 { return commit[:8] } return commit } func isAre(n int) string { if n == 1 { return "is" } return "are" } // upgradeCommand says what should happen when a module's current version moves. func upgradeCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("upgrade", flag.ContinueOnError) together := set.Bool("together", false, "send every machine running it at once, instead of one after another") positionals, err := parseAround(set, args) if err != nil { return err } if len(positionals) == 0 { return errors.New("upgrade [roll-out|record] [--together]") } module := positionals[0] open, err := openStores(ctx) if err != nil { return err } defer open.Close() inv := open.inventory if len(positionals) == 1 { decision, err := inv.UpgradeOf(ctx, module) if err != nil { return err } fmt.Println(sayUpgrade(module, decision)) return nil } var decision inventory.Upgrade switch positionals[1] { case "roll-out": decision = inventory.Upgrade{RollOut: true, Together: *together} case "record": if *together { // Refused rather than ignored: --together only means anything for a roll-out, and // accepting it here would store a preference that never applies and looks like it does. return errors.New("`--together` says how to roll out, so it cannot be given with " + "`record`, which is the choice not to") } decision = inventory.Upgrade{} default: return fmt.Errorf("upgrade roll-out|record — not %q", positionals[1]) } if err := inv.SetUpgradeOf(ctx, module, decision); err != nil { return err } fmt.Println(sayUpgrade(module, decision)) return nil } func sayUpgrade(module string, u inventory.Upgrade) string { if !u.RollOut { return fmt.Sprintf("when %s moves, the mesh records it and the machines running it are "+ "reported as behind", module) } if u.Together { return fmt.Sprintf("when %s moves, every machine running it is sent the new version "+ "together", module) } return fmt.Sprintf("when %s moves, the machines running it are sent the new version one at "+ "a time, stopping at the first that fails", module) } // Announceable is every build this mesh recorded, in the shape the builder announces one. // // **The catalogue asks for this when it starts, and the answer is the graph's foundation** // (novox/hq 04-ISSUES/050). A durable queue keeps what arrived after it existed, so a running // catalogue misses nothing — but the modules built before it first ran were announced to a queue // that did not exist, and on a fresh mesh those are always the same three: the shared base, the // store the catalogue runs on, and the catalogue itself. // // **Announced as fetchable, recorded as what it is** (novox/hq 04-ISSUES/102). A build is // recorded by digest and path; the catalogue hears the builder's own announcements, which name // the store's address, so a replay composes the address back in — the store's address as the // network reaches it NOW, which is the whole point of not having recorded the old one. With no // store on the network yet, the recorded form goes as it is. func (f following) Announceable(ctx context.Context) ([]link.Announcement, error) { builds, err := f.open.inventory.Announceable(ctx) if err != nil { return nil, err } address, err := whereTheStoreIs(ctx, f.open.inventory, "") if err != nil { return nil, err } out := make([]link.Announcement, 0, len(builds)) for _, b := range builds { a := link.Announcement{ Module: b.Module, Commit: b.Commit, Repository: b.Repository, Path: b.Path, Ref: b.Ref, Against: b.Against, } if len(b.Manifest) > 0 { a.Manifest = b.Manifest if address != "" { a.Manifest = routedManifest(b.Manifest, b.Made, address) } } for _, made := range routedArtifacts(b.Made, address) { a.Made = append(a.Made, link.MadeArtifact{ Name: made.Name, Kind: made.Kind, Reference: made.Reference, }) } out = append(out, a) } return out, nil } // notNow marks a store that could not be read right now, so the announcement is held rather than // lost; anything else is returned as it was. func notNow(err error) error { if inventory.Unreachable(err) { return fmt.Errorf("%w: %w", link.ErrTryAgain, err) } return err }