The installer carries a builder and builds the control plane it raises
It carried the thing it was going to run; it now carries the thing that makes it. One artifact either way — but a mesh raised this way holds a control plane it built from a repository and a commit it can name, and can therefore build again. A mesh handed a finished image could not, and had no way to find that out until somebody needed it to. A build step sits between load and bundle, because the bundle must name an image and that image no longer arrives finished. Everything after it is unchanged: a locally built image is named by the digest of its own configuration, which is exactly what the carried one was named by. Refused in preflight when nothing says what to build, so a run that cannot finish says so before it has changed anything.
This commit is contained in:
@@ -43,6 +43,7 @@ type Step string
|
||||
const (
|
||||
StepPreflight Step = "preflight"
|
||||
StepLoad Step = "load"
|
||||
StepBuild Step = "build"
|
||||
StepBundle Step = "bundle"
|
||||
StepApply Step = "apply"
|
||||
StepVerify Step = "verify"
|
||||
@@ -53,14 +54,19 @@ const (
|
||||
StepRetire Step = "retire"
|
||||
)
|
||||
|
||||
// Steps in the order they happen, so a failure can say "step 2 of 10".
|
||||
// Steps in the order they happen, so a failure can say "step 2 of 11".
|
||||
//
|
||||
// The first five make a machine; the last five make a mesh that can maintain itself. They are one
|
||||
// program because they are one procedure — the whole reason the pivot exists is that steps 7 to 9
|
||||
// cannot happen without steps 1 to 5, and steps 1 to 5 leave something that cannot be upgraded
|
||||
// without steps 7 to 9 (novox/hq ADR 0067).
|
||||
// The first six make a machine; the last five make a mesh that can maintain itself. They are one
|
||||
// program because they are one procedure — the whole reason the pivot exists is that steps 8 to 10
|
||||
// cannot happen without steps 1 to 6, and steps 1 to 6 leave something that cannot be upgraded
|
||||
// without steps 8 to 10 (novox/hq ADR 0067).
|
||||
//
|
||||
// **Build sits between load and bundle**, because the bundle has to name an image and that image
|
||||
// no longer arrives finished. The installer carries the builder, loads it, and uses it to produce
|
||||
// the control plane from source (novox/hq ADR 0073) — so what the bundle names is something this
|
||||
// mesh made, out of a repository and a commit it can name, and can therefore make again.
|
||||
var Steps = []Step{
|
||||
StepPreflight, StepLoad, StepBundle, StepApply, StepVerify,
|
||||
StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify,
|
||||
StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire,
|
||||
}
|
||||
|
||||
@@ -118,6 +124,11 @@ type Options struct {
|
||||
// looks installed and cannot upgrade itself.
|
||||
Catalogue string
|
||||
|
||||
// Source is where the control plane is built from — a repository on a mesh that already
|
||||
// exists, and a commit. The installer carries the builder rather than a finished control
|
||||
// plane (novox/hq ADR 0073), so this is what it is told to make.
|
||||
Source Source
|
||||
|
||||
// Registry is where this mesh's own images live, as this machine reaches it. Every node will
|
||||
// pull the control plane from what this says, so on a mesh of more than one machine it must be
|
||||
// an address the others can reach.
|
||||
@@ -171,6 +182,14 @@ type Result struct {
|
||||
ImageTags []string `json:"image-tags,omitempty"`
|
||||
// ImageHeld is true when the machine already held it and nothing was loaded.
|
||||
ImageHeld bool `json:"image-already-held,omitempty"`
|
||||
// Built is what the genesis build produced, and BuiltFrom is the commit it actually built.
|
||||
//
|
||||
// Reported because they are the difference between a mesh that can rebuild its control plane
|
||||
// and one that cannot: a machine holding these can be asked for the same thing again and get
|
||||
// the same thing back.
|
||||
Built string `json:"built,omitempty"`
|
||||
BuiltFrom string `json:"built-from,omitempty"`
|
||||
|
||||
// ImagePredicted is true when Image is the archive's id because nothing was loaded — a dry run
|
||||
// only, and the reason a dry run does not claim to know what would be applied.
|
||||
ImagePredicted bool `json:"image-id-is-a-prediction,omitempty"`
|
||||
@@ -287,7 +306,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
say(" system " + sys.Name())
|
||||
|
||||
// ---- 2. load ------------------------------------------------------------------------
|
||||
say("load — the control plane's image, carried in this installer")
|
||||
say("load — the builder's image, carried in this installer")
|
||||
loaded, err := Load(ctx, d.Run, o.DryRun, say)
|
||||
if err != nil {
|
||||
return result, failed(StepLoad, err)
|
||||
@@ -296,9 +315,23 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
result.ImageArchive, result.ImageTag = loaded.Archive, loaded.Tag
|
||||
result.ImagePredicted = loaded.Predicted
|
||||
|
||||
// ---- 3. bundle ----------------------------------------------------------------------
|
||||
// ---- 3. build -----------------------------------------------------------------------
|
||||
say("build — the control plane, from its own repository and a commit")
|
||||
built, err := BuildControlPlane(ctx, d.Run, loaded.Tag, o.Source, o.DryRun, say)
|
||||
if err != nil {
|
||||
return result, failed(StepBuild, err)
|
||||
}
|
||||
result.Built, result.BuiltFrom = built.Module, built.Commit
|
||||
controlPlaneImage := built.Image
|
||||
if o.DryRun {
|
||||
// Nothing was built, so there is no id to name. The carried builder's own is used only so
|
||||
// the remaining steps have something well-formed to describe; nothing is applied.
|
||||
controlPlaneImage = loaded.ID
|
||||
}
|
||||
|
||||
// ---- 4. bundle ----------------------------------------------------------------------
|
||||
say("bundle — what this machine will be asked to be")
|
||||
rewritten, err := Rewrite(template, loaded.ID)
|
||||
rewritten, err := Rewrite(template, controlPlaneImage)
|
||||
if err != nil {
|
||||
return result, failed(StepBundle, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Source is where the control plane is built from.
|
||||
//
|
||||
// **A commit, not a branch** (novox/hq ADR 0071). The forge a mesh installs from is the trust
|
||||
// anchor for everything that mesh will ever run, and a branch is a moving target somebody else
|
||||
// controls. The installer names what it wants and the builder checks what it got.
|
||||
type Source struct {
|
||||
// Repository is the clone URL, on a mesh that already exists. Not the one being raised.
|
||||
Repository string
|
||||
// Ref is the commit to build.
|
||||
Ref string
|
||||
// Path is the module's directory inside that repository. Empty is its root.
|
||||
Path string
|
||||
}
|
||||
|
||||
// Named reports whether a source was given at all.
|
||||
func (s Source) Named() bool { return strings.TrimSpace(s.Repository) != "" }
|
||||
|
||||
// Check is whether this installer was told enough to build anything.
|
||||
//
|
||||
// **Its own function so it can be asked twice**: once in preflight, before the machine has been
|
||||
// touched, and once at the build, which is where it would otherwise be discovered. The first is
|
||||
// what a person wants — a run that cannot finish should say so before it changes anything — and
|
||||
// the second is what keeps the build honest if it is ever called from somewhere else.
|
||||
func (s Source) Check() error {
|
||||
if !s.Named() {
|
||||
return errors.New(
|
||||
"this installer carries a builder and was not told what to build. Give it --source " +
|
||||
"(a repository on a mesh that already exists) and --source-ref (a commit). It " +
|
||||
"does not guess: what it clones is the trust anchor for everything this mesh " +
|
||||
"will ever run")
|
||||
}
|
||||
if strings.TrimSpace(s.Ref) == "" {
|
||||
return errors.New(
|
||||
"--source was given without --source-ref. Genesis names a commit, because a branch " +
|
||||
"is a moving target somebody else controls and what is cloned here is the trust " +
|
||||
"anchor for everything this mesh will ever run (novox/hq ADR 0071)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Built is what one genesis build produced.
|
||||
type Built struct {
|
||||
// Module is what the manifest called itself, so the installer can say it built the right thing.
|
||||
Module string
|
||||
// Commit is what was actually built, which may not be what was asked for if a ref moved.
|
||||
Commit string
|
||||
// Image is the artifact, named by the digest of its own configuration — the identity a machine
|
||||
// can use with nothing serving it, and the same one the installer used for a carried image.
|
||||
Image string
|
||||
}
|
||||
|
||||
// builderOutput is the part of the builder's one-shot result this needs.
|
||||
type builderOutput struct {
|
||||
Module string `json:"module"`
|
||||
Commit string `json:"commit"`
|
||||
Made []struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Reference string `json:"reference"`
|
||||
} `json:"made"`
|
||||
}
|
||||
|
||||
// BuildControlPlane runs the carried builder once, to produce the control plane from source.
|
||||
//
|
||||
// **This is the step that makes a raised mesh able to maintain itself** (novox/hq ADR 0073). What
|
||||
// comes out is not merely an image: it came from a named repository, a path and a commit, which is
|
||||
// the same description every later rebuild of the control plane will use. A mesh raised this way
|
||||
// can rebuild the thing that runs it. A mesh handed a finished image cannot, and has no way to
|
||||
// discover that until somebody needs it to.
|
||||
//
|
||||
// The builder is given the machine's container runtime and nothing else. It is not given a registry:
|
||||
// there is none yet, and none is needed — the image it produces stays in the runtime of the machine
|
||||
// that will run it, which is this one.
|
||||
func BuildControlPlane(ctx context.Context, run Runner, builderTag string, source Source,
|
||||
dryRun bool, say func(string)) (Built, error) {
|
||||
|
||||
if err := source.Check(); err != nil {
|
||||
return Built{}, err
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
// The build runs containers of its own, which is the whole of what it needs.
|
||||
"-v", "/var/run/docker.sock:/var/run/docker.sock",
|
||||
builderTag,
|
||||
"build", source.Repository, "--ref", source.Ref,
|
||||
}
|
||||
if source.Path != "" {
|
||||
args = append(args, "--path", source.Path)
|
||||
}
|
||||
|
||||
say(fmt.Sprintf("building the control plane from %s at %s", source.Repository, shortRef(source.Ref)))
|
||||
if dryRun {
|
||||
say(" dry run: not built")
|
||||
return Built{}, nil
|
||||
}
|
||||
|
||||
out, err := run(ctx, "docker", args...)
|
||||
if err != nil {
|
||||
return Built{}, fmt.Errorf("the control plane could not be built from %s at %s: %w",
|
||||
source.Repository, shortRef(source.Ref), err)
|
||||
}
|
||||
|
||||
// The builder writes its result to standard output and everything else to standard error, so
|
||||
// what is parsed here is the whole of what it said. Trimmed rather than searched: a parser that
|
||||
// hunts for the first `{` will happily read a brace out of a progress line.
|
||||
var result builderOutput
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &result); err != nil {
|
||||
return Built{}, fmt.Errorf(
|
||||
"the builder finished and what it said is not a result: %w. What it said was: %s",
|
||||
err, firstLine(out))
|
||||
}
|
||||
|
||||
var images []string
|
||||
for _, made := range result.Made {
|
||||
if made.Kind == "image" {
|
||||
images = append(images, made.Reference)
|
||||
}
|
||||
}
|
||||
switch len(images) {
|
||||
case 1:
|
||||
case 0:
|
||||
return Built{}, fmt.Errorf(
|
||||
"%s built, and produced no image. The installer raises the control plane from an "+
|
||||
"image, so there is nothing here to raise", result.Module)
|
||||
default:
|
||||
// Refused rather than guessed at. Picking one of several would work until the day the
|
||||
// order changed, and then raise the wrong thing without saying so.
|
||||
return Built{}, fmt.Errorf(
|
||||
"%s produced %d images, and the installer cannot tell which one is the control "+
|
||||
"plane. A module raised at genesis declares exactly one",
|
||||
result.Module, len(images))
|
||||
}
|
||||
|
||||
say(fmt.Sprintf(" built %s from %s", result.Module, shortRef(result.Commit)))
|
||||
return Built{Module: result.Module, Commit: result.Commit, Image: images[0]}, nil
|
||||
}
|
||||
|
||||
func shortRef(ref string) string {
|
||||
if len(ref) > 8 {
|
||||
return ref[:8]
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
if len(s) > 200 {
|
||||
return s[:200]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An installer that carries a builder and was told nothing refuses, and says what is missing.
|
||||
//
|
||||
// **Exercised rather than assumed.** This is the refusal that stands between a person and a
|
||||
// machine left holding a store, a broker and no control plane — the failure the whole preflight
|
||||
// exists to prevent — and a refusal nothing tests is a refusal nobody has read.
|
||||
func TestAnInstallerWithNothingToBuildRefuses(t *testing.T) {
|
||||
err := Source{}.Check()
|
||||
if err == nil {
|
||||
t.Fatal("a source naming no repository was accepted; nothing would have been built")
|
||||
}
|
||||
for _, want := range []string{"--source", "--source-ref"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s, so it does not say how to fix it: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A repository without a commit refuses too, because a branch is somebody else's moving target.
|
||||
func TestABranchIsNotACommit(t *testing.T) {
|
||||
err := Source{Repository: "https://example.invalid/mesh-control.git"}.Check()
|
||||
if err == nil {
|
||||
t.Fatal("a source with no ref was accepted; genesis would have built whatever a branch pointed at")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--source-ref") {
|
||||
t.Errorf("the refusal does not name the flag that fixes it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And a repository with a commit is enough.
|
||||
func TestARepositoryAndACommitIsEnough(t *testing.T) {
|
||||
if err := (Source{Repository: "https://example.invalid/mesh-control.git", Ref: "a1b2c3d4"}).Check(); err != nil {
|
||||
t.Fatalf("a repository and a commit were refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ type Loaded struct {
|
||||
Predicted bool
|
||||
}
|
||||
|
||||
// Load puts the carried control-plane image into this machine's container runtime, and reports
|
||||
// Load puts the carried builder image into this machine's container runtime, and reports
|
||||
// what the runtime decided to call it.
|
||||
//
|
||||
// **The digest of a configuration is not portable across runtimes, and that is why the id is read
|
||||
|
||||
@@ -32,6 +32,15 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte
|
||||
if image.IsEmpty() {
|
||||
return nil, image.ErrEmpty
|
||||
}
|
||||
// And was it told what to build?
|
||||
//
|
||||
// Asked here rather than at the build, for the same reason as the line above: a run that
|
||||
// cannot finish should say so before it has changed anything. The installer carries a builder
|
||||
// and nothing else (novox/hq ADR 0073), so an installer with no source is an installer that
|
||||
// would raise a store and a broker and then have nothing to raise a control plane from.
|
||||
if err := o.Source.Check(); err != nil {
|
||||
return nil, fmt.Errorf("%w. Nothing has been changed on this machine", err)
|
||||
}
|
||||
saved, err := image.Saved()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user