The installer carries a builder and builds the control plane it raises #9
+1
-1
@@ -3,4 +3,4 @@
|
|||||||
/dist/
|
/dist/
|
||||||
# The placeholder `make bootstrap` moves aside while a saved image is embedded. Ignored so an
|
# The placeholder `make bootstrap` moves aside while a saved image is embedded. Ignored so an
|
||||||
# interrupted release build cannot commit a twenty-megabyte tar by accident.
|
# interrupted release build cannot commit a twenty-megabyte tar by accident.
|
||||||
/internal/image/control-plane.tar.placeholder
|
/internal/image/builder.tar.placeholder
|
||||||
|
|||||||
@@ -58,13 +58,17 @@ host:
|
|||||||
@echo "built for $(SYSTEM) carrying $(BUNDLE)"
|
@echo "built for $(SYSTEM) carrying $(BUNDLE)"
|
||||||
|
|
||||||
# The installer, carrying the control plane's image:
|
# The installer, carrying the control plane's image:
|
||||||
# make bootstrap IMAGE=mesh-control:v1.2.3
|
# make bootstrap IMAGE=mesh-builder:v1.2.3
|
||||||
#
|
#
|
||||||
# The image is BUILT ELSEWHERE and handed over — mesh-control's own `make image` — and embedded
|
# **The carried image is the BUILDER** (novox/hq ADR 0073). It used to be the control plane, on the
|
||||||
# here at release time. Not built on the machine being bootstrapped, and not fetched: the forge
|
# argument that the forge holding the source runs on the mesh, so building at genesis would need a
|
||||||
# that holds mesh-control's source runs on the mesh, so a bootstrap that had to fetch or build the
|
# mesh in order to raise one. That argument was about the *control plane's* source, and it is
|
||||||
# control plane would need a mesh in order to raise one. Carrying it breaks that cycle, the same
|
# answered by ADR 0071: the source comes from a mesh that already exists, which is not the one being
|
||||||
# way carrying the bundle breaks the "copy it onto a machine and run it" one (novox/hq ADR 0005).
|
# raised. What cannot be fetched is the thing that does the fetching, and that is what is carried.
|
||||||
|
#
|
||||||
|
# The image is BUILT ELSEWHERE and handed over — mesh-control's own `make builder-image` — and
|
||||||
|
# embedded here at release time, the same way carrying the bundle breaks the "copy it onto a machine
|
||||||
|
# and run it" cycle (novox/hq ADR 0005).
|
||||||
#
|
#
|
||||||
# The saved image occupies the embed slot for the length of one build and the placeholder goes
|
# The saved image occupies the embed slot for the length of one build and the placeholder goes
|
||||||
# back, exactly as `host:` does with the bundle. Nothing large is ever committed.
|
# back, exactly as `host:` does with the bundle. Nothing large is ever committed.
|
||||||
@@ -84,15 +88,15 @@ host:
|
|||||||
BOOTSTRAP_OUT ?= mesh-bootstrap
|
BOOTSTRAP_OUT ?= mesh-bootstrap
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@test -n "$(IMAGE)" || { echo "IMAGE= is required; an installer carrying no control-plane image cannot raise a mesh"; exit 1; }
|
@test -n "$(IMAGE)" || { echo "IMAGE= is required; an installer carrying no builder image cannot raise a mesh"; exit 1; }
|
||||||
@case "$(IMAGE)" in sha256:*) echo "IMAGE=$(IMAGE) is an image id. The installer identifies the carried image by its tag, because an id is the digest of a configuration that a runtime rewrites as it loads. Pass a name:tag"; exit 1;; esac
|
@case "$(IMAGE)" in sha256:*) echo "IMAGE=$(IMAGE) is an image id. The installer identifies the carried image by its tag, because an id is the digest of a configuration that a runtime rewrites as it loads. Pass a name:tag"; exit 1;; esac
|
||||||
@docker image inspect "$(IMAGE)" >/dev/null 2>&1 || { echo "this machine does not hold $(IMAGE) — build it in mesh-control with 'make image'"; exit 1; }
|
@docker image inspect "$(IMAGE)" >/dev/null 2>&1 || { echo "this machine does not hold $(IMAGE) — build it in mesh-control with 'make image'"; exit 1; }
|
||||||
@test -n "$$(docker image inspect --format '{{len .RepoTags}}' "$(IMAGE)" | grep -v '^0$$')" || { echo "$(IMAGE) has no repository tag, so the saved archive would carry no name the installer can ask a runtime about. Tag it first: docker tag $(IMAGE) mesh-control:<version>"; exit 1; }
|
@test -n "$$(docker image inspect --format '{{len .RepoTags}}' "$(IMAGE)" | grep -v '^0$$')" || { echo "$(IMAGE) has no repository tag, so the saved archive would carry no name the installer can ask a runtime about. Tag it first: docker tag $(IMAGE) mesh-builder:<version>"; exit 1; }
|
||||||
@cp internal/image/control-plane.tar internal/image/control-plane.tar.placeholder
|
@cp internal/image/builder.tar internal/image/builder.tar.placeholder
|
||||||
@docker save --output internal/image/control-plane.tar "$(IMAGE)"
|
@docker save --output internal/image/builder.tar "$(IMAGE)"
|
||||||
@CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$(VERSION)" -o "$(BOOTSTRAP_OUT)" ./cmd/mesh-bootstrap; \
|
@CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$(VERSION)" -o "$(BOOTSTRAP_OUT)" ./cmd/mesh-bootstrap; \
|
||||||
status=$$?; \
|
status=$$?; \
|
||||||
mv internal/image/control-plane.tar.placeholder internal/image/control-plane.tar; \
|
mv internal/image/builder.tar.placeholder internal/image/builder.tar; \
|
||||||
exit $$status
|
exit $$status
|
||||||
@echo "built $(BOOTSTRAP_OUT) carrying $(IMAGE)"
|
@echo "built $(BOOTSTRAP_OUT) carrying $(IMAGE)"
|
||||||
|
|
||||||
|
|||||||
+27
-10
@@ -51,15 +51,16 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
|||||||
version
|
version
|
||||||
|
|
||||||
1 preflight what has to be true before anything is changed
|
1 preflight what has to be true before anything is changed
|
||||||
2 load the control plane's image, carried in this installer
|
2 load the builder's image, carried in this installer
|
||||||
3 bundle the substrate, named for this machine
|
3 build the control plane, from its own repository and a commit
|
||||||
4 apply raise it
|
4 bundle the substrate, named for this machine
|
||||||
5 verify it is up, and the control plane replies
|
5 apply raise it
|
||||||
6 enrol this machine becomes the mesh's first node
|
6 verify it is up, and the control plane replies
|
||||||
7 registry install the module that gives this mesh an image store
|
7 enrol this machine becomes the mesh's first node
|
||||||
8 publish push the control plane's image into it, for its first digest
|
8 registry install the module that gives this mesh an image store
|
||||||
9 control reinstall the control plane as an ordinary module, pinned to that digest
|
9 publish push the control plane's image into it, for its first digest
|
||||||
10 retire drop the temporary control plane; the host removes it
|
10 control reinstall the control plane as an ordinary module, pinned to that digest
|
||||||
|
11 retire drop the temporary control plane; the host removes it
|
||||||
|
|
||||||
--bundle the substrate template to build this machine's bundle from
|
--bundle the substrate template to build this machine's bundle from
|
||||||
(default ` + defaultTemplate + `)
|
(default ` + defaultTemplate + `)
|
||||||
@@ -67,8 +68,14 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
|||||||
(default ` + defaultOut + `)
|
(default ` + defaultOut + `)
|
||||||
--state where this node records what it has applied
|
--state where this node records what it has applied
|
||||||
(default ` + store.DefaultPath + `)
|
(default ` + store.DefaultPath + `)
|
||||||
|
--source the repository the control plane is built from, on a mesh that
|
||||||
|
already exists — not the one being raised
|
||||||
|
--source-ref the commit to build. 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
|
||||||
|
--source-path the module's directory inside that repository, if not its root
|
||||||
--catalog a checkout of the mesh's catalogue, holding the registry's and the
|
--catalog a checkout of the mesh's catalogue, holding the registry's and the
|
||||||
control plane's manifests. Without it this stops after step 5
|
control plane's manifests. Without it this stops after step 6
|
||||||
--node the name this machine is known by (default: its hostname)
|
--node the name this machine is known by (default: its hostname)
|
||||||
--registry where this mesh keeps its own images (default ` + defaultRegistry + `)
|
--registry where this mesh keeps its own images (default ` + defaultRegistry + `)
|
||||||
every node pulls the control plane from this, so on a mesh of more
|
every node pulls the control plane from this, so on a mesh of more
|
||||||
@@ -83,6 +90,10 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
|||||||
--dry-run everything that does not change the machine
|
--dry-run everything that does not change the machine
|
||||||
--json machine-readable output
|
--json machine-readable output
|
||||||
|
|
||||||
|
The installer carries a builder, not a control plane. What raises a mesh is therefore
|
||||||
|
the same thing that will maintain it, and the control plane a mesh ends up running is
|
||||||
|
one it built itself, from a repository and a commit it can name and build again.
|
||||||
|
|
||||||
Genesis is a pivot: a temporary control plane installs the registry that makes it
|
Genesis is a pivot: a temporary control plane installs the registry that makes it
|
||||||
permanent. The temporary one is called temp-mesh-control and the permanent one is
|
permanent. The temporary one is called temp-mesh-control and the permanent one is
|
||||||
called mesh-control, so they are two containers with two owners and there is nothing
|
called mesh-control, so they are two containers with two owners and there is nothing
|
||||||
@@ -175,6 +186,12 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
|
|||||||
set.StringVar(&opts.State, "state", opts.State, "where this node records what it has applied")
|
set.StringVar(&opts.State, "state", opts.State, "where this node records what it has applied")
|
||||||
set.StringVar(&opts.Catalogue, "catalog", opts.Catalogue,
|
set.StringVar(&opts.Catalogue, "catalog", opts.Catalogue,
|
||||||
"a checkout of the mesh's catalogue; without it this stops after the substrate")
|
"a checkout of the mesh's catalogue; without it this stops after the substrate")
|
||||||
|
set.StringVar(&opts.Source.Repository, "source", opts.Source.Repository,
|
||||||
|
"the repository the control plane is built from, on a mesh that already exists")
|
||||||
|
set.StringVar(&opts.Source.Ref, "source-ref", opts.Source.Ref,
|
||||||
|
"the commit to build; a branch is a moving target somebody else controls")
|
||||||
|
set.StringVar(&opts.Source.Path, "source-path", opts.Source.Path,
|
||||||
|
"the module's directory inside that repository, if not its root")
|
||||||
set.StringVar(&opts.Node, "node", opts.Node, "the name this machine is known by")
|
set.StringVar(&opts.Node, "node", opts.Node, "the name this machine is known by")
|
||||||
set.StringVar(&opts.Registry, "registry", opts.Registry, "where this mesh keeps its own images")
|
set.StringVar(&opts.Registry, "registry", opts.Registry, "where this mesh keeps its own images")
|
||||||
set.StringVar(&opts.Host, "host", opts.Host, "the mesh-host binary on this machine")
|
set.StringVar(&opts.Host, "host", opts.Host, "the mesh-host binary on this machine")
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type Step string
|
|||||||
const (
|
const (
|
||||||
StepPreflight Step = "preflight"
|
StepPreflight Step = "preflight"
|
||||||
StepLoad Step = "load"
|
StepLoad Step = "load"
|
||||||
|
StepBuild Step = "build"
|
||||||
StepBundle Step = "bundle"
|
StepBundle Step = "bundle"
|
||||||
StepApply Step = "apply"
|
StepApply Step = "apply"
|
||||||
StepVerify Step = "verify"
|
StepVerify Step = "verify"
|
||||||
@@ -53,14 +54,19 @@ const (
|
|||||||
StepRetire Step = "retire"
|
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
|
// 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 7 to 9
|
// program because they are one procedure — the whole reason the pivot exists is that steps 8 to 10
|
||||||
// cannot happen without steps 1 to 5, and steps 1 to 5 leave something that cannot be upgraded
|
// cannot happen without steps 1 to 6, and steps 1 to 6 leave something that cannot be upgraded
|
||||||
// without steps 7 to 9 (novox/hq ADR 0067).
|
// 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{
|
var Steps = []Step{
|
||||||
StepPreflight, StepLoad, StepBundle, StepApply, StepVerify,
|
StepPreflight, StepLoad, StepBuild, StepBundle, StepApply, StepVerify,
|
||||||
StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire,
|
StepEnrol, StepRegistry, StepPublish, StepControlPlane, StepRetire,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +124,11 @@ type Options struct {
|
|||||||
// looks installed and cannot upgrade itself.
|
// looks installed and cannot upgrade itself.
|
||||||
Catalogue string
|
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
|
// 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
|
// 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.
|
// an address the others can reach.
|
||||||
@@ -171,6 +182,14 @@ type Result struct {
|
|||||||
ImageTags []string `json:"image-tags,omitempty"`
|
ImageTags []string `json:"image-tags,omitempty"`
|
||||||
// ImageHeld is true when the machine already held it and nothing was loaded.
|
// ImageHeld is true when the machine already held it and nothing was loaded.
|
||||||
ImageHeld bool `json:"image-already-held,omitempty"`
|
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
|
// 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.
|
// 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"`
|
ImagePredicted bool `json:"image-id-is-a-prediction,omitempty"`
|
||||||
@@ -287,18 +306,34 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
|||||||
say(" system " + sys.Name())
|
say(" system " + sys.Name())
|
||||||
|
|
||||||
// ---- 2. load ------------------------------------------------------------------------
|
// ---- 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)
|
loaded, err := Load(ctx, d.Run, o.DryRun, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, failed(StepLoad, err)
|
return result, failed(StepLoad, err)
|
||||||
}
|
}
|
||||||
|
// These describe the CARRIED image, which is the builder. What the control plane ends up
|
||||||
|
// being is reported separately, by the build below.
|
||||||
result.Image, result.ImageTags, result.ImageHeld = loaded.ID, loaded.Tags, loaded.Held
|
result.Image, result.ImageTags, result.ImageHeld = loaded.ID, loaded.Tags, loaded.Held
|
||||||
result.ImageArchive, result.ImageTag = loaded.Archive, loaded.Tag
|
result.ImageArchive, result.ImageTag = loaded.Archive, loaded.Tag
|
||||||
result.ImagePredicted = loaded.Predicted
|
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")
|
say("bundle — what this machine will be asked to be")
|
||||||
rewritten, err := Rewrite(template, loaded.ID)
|
rewritten, err := Rewrite(template, controlPlaneImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, failed(StepBundle, err)
|
return result, failed(StepBundle, err)
|
||||||
}
|
}
|
||||||
@@ -369,9 +404,9 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
|||||||
// inside a pull that cannot succeed, three steps from the cause.
|
// inside a pull that cannot succeed, three steps from the cause.
|
||||||
if loaded.Predicted {
|
if loaded.Predicted {
|
||||||
return result, failed(StepBundle, fmt.Errorf(
|
return result, failed(StepBundle, fmt.Errorf(
|
||||||
"the control plane's image id was never confirmed against this machine's runtime, and "+
|
"the builder's image id was never confirmed against this machine's runtime, and the "+
|
||||||
"the bundle was about to be written with it. This is a fault in the installer, not "+
|
"build was about to be run with it. This is a fault in the installer, not in the "+
|
||||||
"in the machine"))
|
"machine"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeBundleFile(o.Out, rewritten.Bundle); err != nil {
|
if err := writeBundleFile(o.Out, rewritten.Bundle); err != nil {
|
||||||
@@ -444,7 +479,12 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
|||||||
|
|
||||||
// ---- 8. publish -----------------------------------------------------------------------
|
// ---- 8. publish -----------------------------------------------------------------------
|
||||||
say("publish — the control plane's image gets its first manifest digest")
|
say("publish — the control plane's image gets its first manifest digest")
|
||||||
published, err := PublishControlPlane(ctx, o, d, loaded.ID, say)
|
// **The image this mesh built, not the one the installer carried.** The carried one is the
|
||||||
|
// builder; publishing it here would put a builder in the registry under the control plane's
|
||||||
|
// name and install it as the control plane — which is exactly what happened the first time
|
||||||
|
// this ran, and presented as a control plane that started, printed a builder's usage, and
|
||||||
|
// exited cleanly over and over.
|
||||||
|
published, err := PublishControlPlane(ctx, o, d, controlPlaneImage, say)
|
||||||
result.PublishedAs, result.PublishedAlready = published.Reference, published.Already
|
result.PublishedAs, result.PublishedAlready = published.Reference, published.Already
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, failed(StepPublish, err)
|
return result, failed(StepPublish, 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
|
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.
|
// 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
|
// **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() {
|
if image.IsEmpty() {
|
||||||
return nil, image.ErrEmpty
|
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()
|
saved, err := image.Saved()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -56,7 +65,7 @@ func Preflight(ctx context.Context, o Options, d Deps, say func(string)) ([]byte
|
|||||||
"Rebuild the installer with a tagged image: `make bootstrap IMAGE=<name>:<tag>`",
|
"Rebuild the installer with a tagged image: `make bootstrap IMAGE=<name>:<tag>`",
|
||||||
carriedID)
|
carriedID)
|
||||||
}
|
}
|
||||||
say(fmt.Sprintf(" control plane %s carried (the archive calls it %s)", tag, carriedID))
|
say(fmt.Sprintf(" builder %s carried (the archive calls it %s)", tag, carriedID))
|
||||||
|
|
||||||
// 2. Is the template there, and is it a substrate?
|
// 2. Is the template there, and is it a substrate?
|
||||||
template, err := os.ReadFile(o.Template)
|
template, err := os.ReadFile(o.Template)
|
||||||
|
|||||||
+11
-5
@@ -30,6 +30,12 @@ import (
|
|||||||
|
|
||||||
// The saved image, replaced at release time by `make bootstrap`.
|
// The saved image, replaced at release time by `make bootstrap`.
|
||||||
//
|
//
|
||||||
|
// **It is the builder, not the control plane** (novox/hq ADR 0073). The installer used to carry
|
||||||
|
// the thing it was going to run; it now carries the thing that makes it. One artifact either way —
|
||||||
|
// but a mesh raised by the second one holds a control plane it built from source, out of the same
|
||||||
|
// repository and path every later rebuild of it will use, and can therefore rebuild it. A mesh
|
||||||
|
// raised by the first held an artifact it could not reproduce and knew nothing about.
|
||||||
|
//
|
||||||
// What is committed here is a placeholder, for the same reason `internal/bundle` commits locks
|
// What is committed here is a placeholder, for the same reason `internal/bundle` commits locks
|
||||||
// that are only comments: `go:embed` refuses to compile against a file that is not there, so a
|
// that are only comments: `go:embed` refuses to compile against a file that is not there, so a
|
||||||
// checkout with nothing embedded would not build at all — and someone reading this repository or
|
// checkout with nothing embedded would not build at all — and someone reading this repository or
|
||||||
@@ -41,18 +47,18 @@ import (
|
|||||||
// the length of one build and then puts the placeholder back — exactly what `make host` does with
|
// the length of one build and then puts the placeholder back — exactly what `make host` does with
|
||||||
// the bundle it embeds.
|
// the bundle it embeds.
|
||||||
//
|
//
|
||||||
//go:embed control-plane.tar
|
//go:embed builder.tar
|
||||||
var saved []byte
|
var saved []byte
|
||||||
|
|
||||||
// ErrEmpty means this installer carries no control-plane image.
|
// ErrEmpty means this installer carries no builder image.
|
||||||
//
|
//
|
||||||
// A separate error rather than a message, so the caller can refuse in preflight — before a
|
// A separate error rather than a message, so the caller can refuse in preflight — before a
|
||||||
// machine has been touched — instead of discovering it at the load, after the runtime has been
|
// machine has been touched — instead of discovering it at the load, after the runtime has been
|
||||||
// probed and a bundle has been written.
|
// probed and a bundle has been written.
|
||||||
var ErrEmpty = errors.New(
|
var ErrEmpty = errors.New(
|
||||||
"this mesh-bootstrap carries no control-plane image, so it cannot raise a mesh. A release " +
|
"this mesh-bootstrap carries no builder image, so it cannot raise a mesh. A release build " +
|
||||||
"build embeds one: `make bootstrap IMAGE=<image>` in the mesh-host repository, where " +
|
"embeds one: `make bootstrap IMAGE=<image>` in the mesh-host repository, where <image> " +
|
||||||
"<image> is a control-plane image already built from the mesh-control source")
|
"is a mesh-builder image already built from the mesh-control source")
|
||||||
|
|
||||||
// IsEmpty reports whether anything was built in.
|
// IsEmpty reports whether anything was built in.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user