Three readers did not follow a moved foundation port (novox/hq 04-ISSUES/102),
and each took the control-node down in its own way: the control plane's own
store and broker connections, sealed at genesis with the port inside; and every
build the mesh ever recorded, kept as `<registry>:<port>/<module>/<artifact>@…`.
The control plane cannot open its own sealed connections to move a port, and it
cannot bind the store as a consumer would — a binding mints a credential. So its
settings get a third twin, `NAME_PORT`, read on top of the sealed value by the
store, the broker, the management API and the bus connection, and filled into
its container by a placeholder that names a seat, `${seat:mesh-store:5432}`,
from the node's given or mesh-assigned ports — never the manifest's number, and
empty when the mesh has nothing to add, so what genesis wrote stands. A value
that is still a placeholder is nothing said, aloud: the manifest naming it lands
in the next commit, once every control plane that composes it knows it.
A build is now recorded by digest and path — `artifact-store://<module>/<artifact>@…`
— and the store's address is composed in where a reference is used: the
declaration, the trust file, the bases a build is handed, a replay to the
catalogue. Over the network as `<node>.internal:<port>`; on the store's own node
before any network exists — every genesis push before its "network" step — by
loopback. A reference recorded before this, with an address, is re-routed the
same way when the mesh built it. The trust file and every provider's address
come from one derivation: the node's given port, over the mesh's assignment,
over the manifest's number.
novox/hq 04-ISSUES/102
221 lines
7.4 KiB
Go
221 lines
7.4 KiB
Go
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 <module> [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 <module> 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
|
|
}
|