Working out what a machine should be reaches the identity context for its certificate and the licence context for its model access. Both were opened — and waited on — inside functions called for every node in a push. Two machines hid it. Fifty would be fifty connect-and-wait cycles for data that does not change while the push runs. So a command holds what it has open, and passes it. Each context is opened on first use rather than up front, because most commands need one and paying to reach three would be the same waste from the other side. The contexts stay separate, which is the point: this is one struct holding three connections to three databases, not one connection to a shared one. No context reaches another's store, and each still holds only its own credential (novox/hq ADR 0008). A pure move again — the gate is green before and after, and no test changed.
442 lines
15 KiB
Go
442 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-control/internal/broker"
|
|
"github.com/novox/mesh-control/internal/catalogue"
|
|
"github.com/novox/mesh-control/internal/inventory"
|
|
"github.com/novox/mesh-control/internal/link"
|
|
)
|
|
|
|
// asking a build machine for a module, and what came back.
|
|
//
|
|
// Split out of main.go, which had reached 2,769 lines because appending was always the
|
|
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
|
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
|
|
|
// buildCommand builds a module from its source and records what came out.
|
|
//
|
|
// **Run where there is a container runtime**, which is why it is a command rather than something
|
|
// the control plane does on its own: building needs to run things on a machine, and what the
|
|
// control plane may send a machine is bounded by the declaration language. This is the shape the
|
|
// builder module will take when it is given work over the broker; today a person runs it, and the
|
|
// mesh records the result the same way either way.
|
|
func buildCommand(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("build", flag.ContinueOnError)
|
|
ref := set.String("ref", "", "the branch, tag or commit to build")
|
|
wait := set.Duration("wait", 10*time.Minute, "how long to wait for a builder to answer")
|
|
dryRun := set.Bool("dry-run", false, "build and print the manifest, recording nothing")
|
|
// Every module whose source has moved, rather than one named repository.
|
|
//
|
|
// **The mirror of `push --behind`, and the same argument** (novox/hq ADR 0010): the mesh
|
|
// already knows which modules are behind their source, so making a person read that list and
|
|
// retype each repository is asking them to be the loop. Naming a repository and asking which
|
|
// ones need building are different requests, so they are not combined.
|
|
behind := set.Bool("behind", false, "every module the mesh holds older than its source has")
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *behind {
|
|
if len(positionals) != 0 {
|
|
return errors.New("build <repository> or build --behind, not both: one names a " +
|
|
"repository and the other asks which need building")
|
|
}
|
|
return buildBehind(ctx, *wait)
|
|
}
|
|
if len(positionals) != 1 {
|
|
return errors.New("build <repository> [--ref R] [--wait D] [--dry-run]")
|
|
}
|
|
|
|
if *dryRun {
|
|
return buildAndShow(ctx, positionals[0], *ref, *wait)
|
|
}
|
|
return buildOne(ctx, positionals[0], *ref, *wait)
|
|
}
|
|
|
|
// buildFrom turns what a builder said into what the mesh keeps.
|
|
func buildFrom(result link.BuildResult) inventory.Build {
|
|
kept := inventory.Build{
|
|
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
|
Commit: result.Commit, On: result.On, Failed: result.Failed,
|
|
}
|
|
for _, made := range result.Made {
|
|
kept.Made = append(kept.Made, inventory.Artifact{
|
|
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
|
})
|
|
}
|
|
// The module name comes from the manifest, which only exists when the build got that far.
|
|
if len(result.Manifest) > 0 {
|
|
if m, err := catalogue.ParseManifest(result.Manifest); err == nil {
|
|
kept.Module = m.Module
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// buildsCommand says what has been built lately.
|
|
func buildsCommand(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("builds", flag.ContinueOnError)
|
|
limit := set.Int("n", 20, "how many to show")
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
module := ""
|
|
if len(positionals) == 1 {
|
|
module = positionals[0]
|
|
} else if len(positionals) > 1 {
|
|
return errors.New("builds [<module>] [-n N]")
|
|
}
|
|
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
builds, err := inv.Builds(ctx, module, *limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(builds) == 0 {
|
|
// Said rather than printed as nothing: an empty list and a failed read must never look
|
|
// the same, and getting here means the store answered.
|
|
if module != "" {
|
|
fmt.Printf("nothing has been built for %s\n", module)
|
|
return nil
|
|
}
|
|
fmt.Println("nothing has been built yet")
|
|
return nil
|
|
}
|
|
|
|
for _, b := range builds {
|
|
what := b.Module
|
|
if what == "" {
|
|
// It failed before knowing what it was building, which is most of the interesting
|
|
// failures. The repository is what a person has to go and look at.
|
|
what = "?"
|
|
}
|
|
outcome := "built " + short(b.Commit)
|
|
if !b.Worked() {
|
|
outcome = "failed"
|
|
}
|
|
fmt.Printf("%-18s %-14s %-10s %s\n",
|
|
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"))
|
|
fmt.Printf(" %s", b.Repository)
|
|
if b.Ref != "" {
|
|
fmt.Printf(" at %s", b.Ref)
|
|
}
|
|
fmt.Println()
|
|
for _, made := range b.Made {
|
|
fmt.Printf(" %-10s %s\n", made.Kind, made.Reference)
|
|
}
|
|
if !b.Worked() {
|
|
// The builder's own first line. The whole failure is often a build log, and printing
|
|
// it here would bury every other row.
|
|
fmt.Printf(" %s\n", firstLine(b.Failed))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// builderCommand issues a build machine its own broker credential.
|
|
//
|
|
// **A build machine is not a node**, and giving it a node's account would let it read another
|
|
// machine's declarations. This is narrower and different: read the build queue, write the
|
|
// exchange and an asker's reply queue, and nothing else.
|
|
//
|
|
// Issued rather than assumed, because until this the builder used whatever credential it was
|
|
// handed — which in practice meant the broker's own administrative one. A program documented as
|
|
// holding its own credential and given somebody else's is worse than one with no story at all.
|
|
func builderCommand(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("builder issue", flag.ContinueOnError)
|
|
// Which machine will use it. Given, the credential is delivered by the mesh rather than
|
|
// printed for somebody to carry — which is the difference between the builder being a module
|
|
// and being a program somebody configures.
|
|
forNode := set.String("node", "",
|
|
"the machine that will run it, so the mesh delivers the credential instead of printing it")
|
|
module := set.String("module", "builder", "the module on that machine that will read it")
|
|
positionals, err := parseAround(set, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(positionals) != 2 || positionals[0] != "issue" {
|
|
return errors.New("builder issue <name> [--node <machine>]")
|
|
}
|
|
name := positionals[1]
|
|
|
|
management, err := broker.ManagementFromEnvironment()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The same shape of secret a token carries: enough entropy that guessing is not a strategy,
|
|
// and safe to put in a URL because that is where it goes.
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return err
|
|
}
|
|
password := base64.RawURLEncoding.EncodeToString(raw)
|
|
if err := management.CreateBuilderAccount(ctx, name, password); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("broker account %s created, scoped to the %s queue and the %s exchange\n\n",
|
|
name, link.BuildQueue, link.Exchange)
|
|
|
|
if *forNode != "" {
|
|
known, err := broker.FromEnvironment()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err)
|
|
}
|
|
inv, err := openInventory(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer inv.Close()
|
|
|
|
// The URL and what verifies the broker, together. A mesh's broker presents a certificate
|
|
// of the mesh's own, which is in no public trust store — so a URL on its own reaches only
|
|
// a broker somebody else vouches for, and the connection fails at TLS with an error about
|
|
// an unknown authority rather than about a missing pin.
|
|
//
|
|
// **The same two facts a node's token carries** (novox/hq ADR 0004), delivered the same
|
|
// way: out of band relative to the broker, so what is trusted does not come from the thing
|
|
// being trusted.
|
|
held, err := json.Marshal(struct {
|
|
URL string `json:"url"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
}{
|
|
URL: fmt.Sprintf("amqps://%s:%s@%s/", name, password, known.Address),
|
|
Fingerprint: known.Fingerprint,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := inv.AcceptSecretForModule(ctx, *forNode, *module, "broker", string(held)); err != nil {
|
|
return err
|
|
}
|
|
// Not printed. It is sealed to that machine and the mesh cannot read it back, which is
|
|
// the whole point — printing it here would put the one copy that matters on a terminal.
|
|
fmt.Printf(" sealed to %s, for the %s module. It arrives with the next push.\n",
|
|
*forNode, *module)
|
|
fmt.Printf(" run `push %s` to send it\n", *forNode)
|
|
return nil
|
|
}
|
|
|
|
// The whole line only when the address is known. A URL with a placeholder where the host
|
|
// should be is a URL somebody pastes and then debugs, and the placeholder is the last thing
|
|
// they look at.
|
|
if known, err := broker.FromEnvironment(); err == nil {
|
|
fmt.Printf(" MESH_BROKER_AMQP=amqps://%s:%s@%s/\n\n", name, password, known.Address)
|
|
} else {
|
|
fmt.Printf(" the password is %s\n\n", password)
|
|
fmt.Printf(" This control plane has no %s, so it cannot say where the broker is.\n"+
|
|
" Put the password in MESH_BROKER_AMQP on the build machine.\n\n",
|
|
broker.AddressVar)
|
|
}
|
|
// Shown once, like a token, and for the same reason: what is stored is the broker's own hash
|
|
// of it, and a control plane that could show it back would be a control plane that holds it.
|
|
fmt.Println("This is the only time it is shown.")
|
|
return nil
|
|
}
|
|
|
|
// buildBehind builds every module the mesh holds older than its source has.
|
|
//
|
|
// **This is the loop novox/hq ADR 0010 replaced a pipeline with, closed.** The mesh already
|
|
// records where each module came from and what its source last had; until this, a person read
|
|
// that list and retyped each repository — which is a person being the loop, and the thing a
|
|
// pipeline was doing before it was taken away.
|
|
//
|
|
// Each is built and recorded on its own. **One failing does not stop the others**, for the same
|
|
// reason one broken module no longer blocks a machine's whole declaration: a mesh where one bad
|
|
// repository holds back nine good ones is a mesh where nobody dares add the tenth.
|
|
func buildBehind(ctx context.Context, wait time.Duration) error {
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
|
|
held, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var stale []inventory.Entry
|
|
for _, e := range held {
|
|
if !e.Source.Current() {
|
|
stale = append(stale, e)
|
|
}
|
|
}
|
|
if len(stale) == 0 {
|
|
// Said rather than doing nothing quietly: "nothing needed building" and "this did not
|
|
// run" must never look the same.
|
|
fmt.Println("every module the mesh holds is what its source last had")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%d module(s) behind their source:\n", len(stale))
|
|
for _, e := range stale {
|
|
fmt.Printf(" %s %s < %s\n",
|
|
e.Manifest.Module, short(e.Source.BuiltFrom), short(e.Source.Head))
|
|
}
|
|
fmt.Println()
|
|
|
|
var failed []string
|
|
for _, e := range stale {
|
|
fmt.Printf("--- %s\n", e.Manifest.Module)
|
|
// Its own recorded ref, not its head commit: a module tracking a branch should be built
|
|
// from that branch, and pinning to the commit the mesh happened to notice would quietly
|
|
// turn a tracked branch into a pin.
|
|
if err := buildOne(ctx, e.Source.Repository, e.Source.Ref, wait); err != nil {
|
|
fmt.Printf(" %v\n", err)
|
|
failed = append(failed, e.Manifest.Module)
|
|
}
|
|
}
|
|
|
|
if len(failed) > 0 {
|
|
return fmt.Errorf("%d of %d could not be built: %s",
|
|
len(failed), len(stale), strings.Join(failed, ", "))
|
|
}
|
|
fmt.Printf("\n%d module(s) built. `push --behind` sends them to the machines running them\n",
|
|
len(stale))
|
|
return nil
|
|
}
|
|
|
|
// buildOne asks a build machine for one repository and records everything that came back.
|
|
//
|
|
// Separated from the command so `--behind` can walk a list without a second path to the same act.
|
|
func buildOne(ctx context.Context, repository, ref string, wait time.Duration) error {
|
|
ident, err := openIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ident.Close()
|
|
|
|
server, err := link.Connect(nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer server.Close()
|
|
|
|
// Correlated by something the control plane makes, not by the module's name: two builds of one
|
|
// module can be in flight, and the second answer is not the first one's.
|
|
request := link.BuildRequest{
|
|
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
|
Repository: repository,
|
|
Ref: ref,
|
|
}
|
|
fmt.Printf("asked for %s", request.Repository)
|
|
if ref != "" {
|
|
fmt.Printf(" at %s", ref)
|
|
}
|
|
fmt.Println()
|
|
|
|
result, err := link.RequestBuild(ctx, server.Channel(), request, wait)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
|
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
|
// something.
|
|
open, err := openStores(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer open.Close()
|
|
inv := open.inventory
|
|
if err := inv.RecordBuild(ctx, buildFrom(result)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if result.Failed != "" {
|
|
// The builder's own words. Wrapping them in something about the control plane would put
|
|
// two explanations between a person and a build log.
|
|
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
|
}
|
|
|
|
for _, made := range result.Made {
|
|
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
|
|
}
|
|
|
|
// Parsed with the same parser a hand-written manifest goes through. A second path would be a
|
|
// second thing to disagree about what a manifest is.
|
|
manifest, err := catalogue.ParseManifest(result.Manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
|
result.On, result.Repository, err)
|
|
}
|
|
|
|
// Recorded with where it came from, so "is this current?" is answerable without building it
|
|
// again (novox/hq ADR 0009).
|
|
if err := inv.RegisterModule(ctx, manifest, inventory.Source{
|
|
Repository: result.Repository, Ref: result.Ref,
|
|
BuiltFrom: result.Commit, Head: result.Commit,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
|
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
|
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
|
return nil
|
|
}
|
|
|
|
// buildAndShow builds and prints the manifest without recording anything.
|
|
func buildAndShow(ctx context.Context, repository, ref string, wait time.Duration) error {
|
|
ident, err := openIdentity(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer ident.Close()
|
|
server, err := link.Connect(nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer server.Close()
|
|
|
|
result, err := link.RequestBuild(ctx, server.Channel(), link.BuildRequest{
|
|
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()), Repository: repository, Ref: ref,
|
|
}, wait)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result.Failed != "" {
|
|
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
|
}
|
|
manifest, err := catalogue.ParseManifest(result.Manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
|
result.On, result.Repository, err)
|
|
}
|
|
body, err := json.MarshalIndent(manifest, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(string(body))
|
|
return nil
|
|
}
|
|
|
|
// answers is what the three questions came back with, read once.
|
|
type answers struct {
|
|
wrong []inventory.Doing
|
|
nodes []inventory.Node
|
|
quiet []inventory.Node
|
|
behind map[string][]string
|
|
sources map[string]inventory.Source
|
|
// waiting is every machine not running what the mesh would send it.
|
|
waiting []inventory.Machine
|
|
}
|