Steps 6 to 10, which turn a substrate into a mesh that can maintain itself
(novox/hq ADR 0067).
6 enrol a node record, a token, `mesh-host enrol`, and the host agent
running. Proved by the mesh having HEARD from the node, not by a
process existing: a host that cannot reach the broker looks exactly
like a successful install until the first push applies nothing.
7 registry the module that gives this mesh an image store, registered from a
--catalog checkout, assigned and pushed. Its image is upstream and
never built (04-ISSUES/029) — a placeholder digest there is refused.
Verified by asking `/v2/`, because a container that is up is not a
registry that serves.
8 publish the carried image pushed into that registry, which assigns it the
first manifest digest it has ever had. This is the hinge: without
it the mesh works and can never upgrade itself.
9 control the control plane registered as an ordinary module pinned to that
digest, with the substrate's own store connections delivered
through `secret accept` — read out of the bundle that made them,
because the mesh cannot invent a credential that predates it.
10 retire the temporary control plane dropped from the bundle and removed by
the host's ordinary removal pass.
Every step asks before it acts and reports "already done". No step leaves the
machine without a control plane: steps 9 and 10 overlap deliberately, and two
stateless control planes are untidy rather than broken.
mesh-control's `internal/builder`.PublishImage is mirrored rather than imported —
tier 0 depends on nothing that must be installed first — with one correction: the
digest is chosen from RepoDigests by repository instead of taken as element zero,
so an image pushed to two registries cannot silently pin this mesh to the wrong
one.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
284 lines
11 KiB
Go
284 lines
11 KiB
Go
// Command mesh-bootstrap brings a mesh into existence on a bare machine.
|
|
//
|
|
// Tier 0, beside `mesh-host` and not inside it. Bootstrapping is done by hand and it changes a
|
|
// machine, which is what tier 0 is (novox/hq 03-DESIGN/01-to-be/05-the-node-host.md) — but
|
|
// `mesh-host` states of itself that it connects to nothing and listens on nothing and that what it
|
|
// applies comes from a file, and that is the whole reason an always-running root daemon can be
|
|
// audited by reading one page. An installer that loads images and interrogates a control plane
|
|
// cannot be folded into it without making that sentence false. Same tier, same repository,
|
|
// different program.
|
|
//
|
|
// Genesis is a pivot (novox/hq ADR 0067). It raises a substrate whose control plane is named by the
|
|
// digest of its own configuration — legal exactly where nothing could have served an image — then
|
|
// enrols this machine, installs the registry module, pushes that image into it to get the manifest
|
|
// digest it has never had, reinstalls the control plane as an ordinary module pinned to it, and
|
|
// drops the temporary one. Without --catalog it stops after the substrate and says why.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/apply"
|
|
"github.com/novox/mesh-host/internal/bootstrap"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// version is stamped at build time. Unset in a development build, and said so rather than
|
|
// defaulted to something that looks like a release.
|
|
var version = "development build"
|
|
|
|
const (
|
|
defaultTemplate = "substrate.lock"
|
|
defaultOut = "/var/lib/mesh-host/substrate.lock"
|
|
defaultRegistry = "127.0.0.1:5000"
|
|
defaultHost = "/usr/local/bin/mesh-host"
|
|
defaultService = "mesh-host.service"
|
|
)
|
|
|
|
const usage = `mesh-bootstrap — make a bare machine into a mesh
|
|
|
|
bootstrap the ten steps below (the default)
|
|
version
|
|
|
|
1 preflight what has to be true before anything is changed
|
|
2 load the control plane's image, carried in this installer
|
|
3 bundle the substrate, named for this machine
|
|
4 apply raise it
|
|
5 verify it is up, and the control plane replies
|
|
6 enrol this machine becomes the mesh's first node
|
|
7 registry install the module that gives this mesh an image store
|
|
8 publish push the control plane's image into it, for its first digest
|
|
9 control reinstall the control plane as an ordinary module, pinned to that digest
|
|
10 retire drop the temporary control plane; the host removes it
|
|
|
|
--bundle the substrate template to build this machine's bundle from
|
|
(default ` + defaultTemplate + `)
|
|
--out where the produced bundle is written, for a person to read
|
|
(default ` + defaultOut + `)
|
|
--state where this node records what it has applied
|
|
(default ` + store.DefaultPath + `)
|
|
--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
|
|
--node the name this machine is known by (default: its hostname)
|
|
--registry where this mesh keeps its own images (default ` + defaultRegistry + `)
|
|
every node pulls the control plane from this, so on a mesh of more
|
|
than one machine it must be an address the others can reach
|
|
--host the mesh-host binary on this machine (default ` + defaultHost + `)
|
|
--host-service the unit that supervises it (default ` + defaultService + `)
|
|
--host-in-background start the host unsupervised instead. It does not survive
|
|
a reboot. This is what a lab does and what no real machine should
|
|
--system which operating system this is; by default it is asked
|
|
--timeout how long any single probe may take (default 30s)
|
|
--wait how long a thing that is merely starting is given (default 3m)
|
|
--dry-run everything that does not change the machine
|
|
--json machine-readable output
|
|
|
|
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
|
|
called mesh-control, so they are two containers with two owners and there is nothing
|
|
to hand over.
|
|
|
|
Every step is idempotent: run it again after fixing whatever it named, and the steps
|
|
that already succeeded say so.
|
|
`
|
|
|
|
func main() {
|
|
// Ctrl-C must stop the installer, not be swallowed by whatever it is waiting for — and it
|
|
// waits on pulls, on a runtime starting, and on a control plane opening its stores.
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
command, opts, jsonOut, err := parseArgs(os.Args[1:])
|
|
if err == nil {
|
|
err = run(ctx, command, opts, jsonOut)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "mesh-bootstrap: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// parseArgs takes an optional subcommand first, then its flags.
|
|
//
|
|
// Parsed in a loop for the reason `mesh-host` records: the standard library stops at the FIRST
|
|
// non-flag argument, so a flag sitting after one is silently dropped and the command exits zero
|
|
// having ignored what it was asked. That fault has been paid for twice in this repository and is
|
|
// not being paid for a third time.
|
|
func parseArgs(args []string) (string, bootstrap.Options, bool, error) {
|
|
opts := bootstrap.Options{
|
|
Template: defaultTemplate,
|
|
Out: defaultOut,
|
|
State: store.DefaultPath,
|
|
Registry: defaultRegistry,
|
|
Host: defaultHost,
|
|
// The machine's own name, because that is what a person already calls it and an installer
|
|
// inventing a different one would leave the mesh naming a machine nobody recognises. It is
|
|
// read here rather than inside the bootstrap so that --node overrides a fact rather than a
|
|
// default computed halfway through.
|
|
Node: hostname(),
|
|
HostService: defaultService,
|
|
// Longer than the host's 10s: these probes reach a container runtime that may be busy
|
|
// pulling, and a probe that times out on a working machine is a false refusal.
|
|
Timeout: 30 * time.Second,
|
|
// A socket-activated runtime queued behind the network, and a control plane running its
|
|
// first `initdb`-shaped wait, are both minutes rather than seconds.
|
|
Wait: 3 * time.Minute,
|
|
}
|
|
var jsonOut bool
|
|
|
|
command := "bootstrap"
|
|
if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' {
|
|
command = args[0]
|
|
args = args[1:]
|
|
}
|
|
|
|
set := newFlagSet(&opts, &jsonOut)
|
|
var positionals []string
|
|
rest := args
|
|
for {
|
|
if err := set.Parse(rest); err != nil {
|
|
return "", opts, false, err
|
|
}
|
|
rest = set.Args()
|
|
if len(rest) == 0 {
|
|
break
|
|
}
|
|
positionals = append(positionals, rest[0])
|
|
rest = rest[1:]
|
|
}
|
|
|
|
// Refused rather than ignored: a mistyped argument that changes nothing and reports success is
|
|
// worse than an error, and this program's whole job is to change a machine.
|
|
if len(positionals) > 0 {
|
|
return "", opts, false, fmt.Errorf(
|
|
"unexpected argument %q — try `mesh-bootstrap help`", positionals[0])
|
|
}
|
|
return command, opts, jsonOut, nil
|
|
}
|
|
|
|
func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
|
|
set := flag.NewFlagSet("mesh-bootstrap", flag.ContinueOnError)
|
|
set.SetOutput(os.Stderr)
|
|
set.Usage = func() { fmt.Fprint(os.Stderr, usage) }
|
|
set.StringVar(&opts.Template, "bundle", opts.Template, "the substrate template to build from")
|
|
set.StringVar(&opts.Out, "out", opts.Out, "where the produced bundle is written")
|
|
set.StringVar(&opts.State, "state", opts.State, "where this node records what it has applied")
|
|
set.StringVar(&opts.Catalogue, "catalog", opts.Catalogue,
|
|
"a checkout of the mesh's catalogue; without it this stops after the substrate")
|
|
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.Host, "host", opts.Host, "the mesh-host binary on this machine")
|
|
set.StringVar(&opts.HostService, "host-service", opts.HostService, "the unit that supervises it")
|
|
set.BoolVar(&opts.HostInBackground, "host-in-background", false,
|
|
"start the host unsupervised; it does not survive a reboot")
|
|
set.StringVar(&opts.System, "system", opts.System, "which operating system this is")
|
|
set.DurationVar(&opts.Timeout, "timeout", opts.Timeout, "how long any single probe may take")
|
|
set.DurationVar(&opts.Wait, "wait", opts.Wait, "how long something merely starting is given")
|
|
set.BoolVar(&opts.DryRun, "dry-run", false, "everything that does not change the machine")
|
|
set.BoolVar(jsonOut, "json", false, "machine-readable output")
|
|
return set
|
|
}
|
|
|
|
func run(ctx context.Context, command string, opts bootstrap.Options, jsonOut bool) error {
|
|
switch command {
|
|
case "bootstrap":
|
|
say := func(line string) {
|
|
if !jsonOut {
|
|
fmt.Println(line)
|
|
}
|
|
}
|
|
result, err := bootstrap.Run(ctx, opts, bootstrap.Deps{
|
|
Run: apply.ExecRunner,
|
|
Dial: dial,
|
|
Fetch: fetch,
|
|
}, say)
|
|
|
|
// Printed whichever way it went. What the installer got through before it stopped is on
|
|
// the machine either way, and a report that only exists on success describes a machine
|
|
// nobody has (novox/hq ADR 0018).
|
|
if jsonOut {
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
if encodeErr := encoder.Encode(result); encodeErr != nil && err == nil {
|
|
return encodeErr
|
|
}
|
|
}
|
|
return err
|
|
|
|
case "version":
|
|
fmt.Println(version)
|
|
return nil
|
|
|
|
case "help", "-h", "--help":
|
|
fmt.Fprint(os.Stderr, usage)
|
|
return nil
|
|
|
|
default:
|
|
return fmt.Errorf("unknown command %q — try `mesh-bootstrap help`", command)
|
|
}
|
|
}
|
|
|
|
// hostname is what this machine calls itself, or empty.
|
|
//
|
|
// Empty rather than a guess: a machine that cannot say its own name is one the installer must be
|
|
// told about, and `mesh-bootstrap-0` would be a name in the mesh's records that matches nothing
|
|
// anybody types anywhere else. The refusal happens at step 6, where the name is first needed.
|
|
func hostname() string {
|
|
name, err := os.Hostname()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|
|
|
|
// fetch asks an HTTP endpoint and reports what it said.
|
|
//
|
|
// Plain HTTP, and only at the mesh's own registry: it is reached over the mesh's private network,
|
|
// which is already the encrypted and authenticated thing, and a second layer inside it would be
|
|
// certificates to issue and rotate for no property the first does not have (mesh-control's
|
|
// `internal/builder` pushes to it on the same reasoning).
|
|
//
|
|
// The body is read with a limit. What is asked for is a status and a short JSON answer, and a
|
|
// registry that answered with a gigabyte would otherwise be an installer that never returns.
|
|
func fetch(ctx context.Context, url string) (int, string, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
response, err := http.DefaultClient.Do(request)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
said, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
if err != nil {
|
|
return response.StatusCode, "", err
|
|
}
|
|
return response.StatusCode, string(said), nil
|
|
}
|
|
|
|
// dial answers whether a TCP address responds.
|
|
//
|
|
// A connection rather than a ping or a name lookup: what has to work is a pull, and a pull opens a
|
|
// connection to exactly this address. A machine whose DNS resolves and whose route is missing
|
|
// passes a lookup and fails the thing that matters.
|
|
func dial(ctx context.Context, address string) error {
|
|
var dialer net.Dialer
|
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return conn.Close()
|
|
}
|