465 lines
20 KiB
Go
465 lines
20 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 foundation 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 foundation and says why.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"bufio"
|
|
"github.com/novox/mesh-host/internal/apply"
|
|
"github.com/novox/mesh-host/internal/bootstrap"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
"strings"
|
|
)
|
|
|
|
// 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 = "foundation.lock"
|
|
defaultOut = "/var/lib/mesh-host/foundation.lock"
|
|
defaultRegistry = "127.0.0.1:5000"
|
|
defaultHost = "/usr/local/bin/mesh-host"
|
|
// The unit this project actually packages, in `packaging/`. It said `mesh-host.service`, which
|
|
// is not a unit anybody installs — so on a machine with the packaged unit the installer looked
|
|
// for something absent, and told the operator a real machine needs it installed when it was.
|
|
defaultService = "nox-mesh-host.service"
|
|
)
|
|
|
|
const usage = `mesh-bootstrap — make a bare machine into a mesh
|
|
|
|
bootstrap the eighteen steps below (the default)
|
|
version
|
|
|
|
1 preflight what has to be true before anything is changed
|
|
2 load the builder's image, carried in this installer
|
|
3 build the control plane, from its own repository and a commit
|
|
4 bundle the foundation, named for this machine
|
|
5 apply raise it
|
|
6 verify it is up, and the control plane replies
|
|
7 enrol this machine becomes the mesh's first node
|
|
8 registry install the module that gives this mesh an image store
|
|
9 publish push the control plane's image into it, for its first digest
|
|
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
|
|
12 builder publish the carried builder and install it, so this mesh can
|
|
make the rest of the catalogue rather than be handed it
|
|
|
|
Twelve make a mesh that RUNS. The rest make one that WORKS, asking where a
|
|
human must choose — a run without a terminal answers with the flags below:
|
|
|
|
13 base build the shared toolchain and runtime everything with code
|
|
stands on
|
|
14 store build and install postgres — a database provider, which the
|
|
foundation's own store is not
|
|
15 catalogue build and install the module graph
|
|
16 network choose the private network (--private-network), place this
|
|
machine as its hub (--endpoint, --site)
|
|
17 filter choose the packet filter (--packet-filter) — required, so the
|
|
question is which, not whether
|
|
18 extras anything beyond the floor (--extras)
|
|
|
|
--bundle the foundation 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 + `)
|
|
--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, the
|
|
control plane's and the builder's manifests. Without it this stops
|
|
after step 6
|
|
--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
|
|
|
|
--tools-source the repository the shared base is built from
|
|
--tools-ref what of it to build (default main)
|
|
--catalog-source the catalogue REPOSITORY, for building its modules;
|
|
--catalog is the checkout that says what they are
|
|
--catalog-ref what of it to build (default main)
|
|
--sdk-source the repository the shared library is built from
|
|
--sdk-ref what of it to build (default main)
|
|
--private-network which private network to run (wireguard)
|
|
--endpoint host:port other machines dial for it; derived from the
|
|
broker address when unsaid
|
|
--site where this machine sits (default main)
|
|
--packet-filter which packet filter to run (nftables)
|
|
--extras catalogue modules beyond the floor, comma-separated
|
|
|
|
The foundation's ports are this machine's, each checked free before anything is
|
|
raised and kept as the node's setting for the module that binds it:
|
|
--store-port 5432 --bus-port 5671 --amqp-port 5672 --management-port 15672
|
|
--registry-port 5000 (follows --registry, and must agree with it)
|
|
--packages-port 3000 --hub-port 51820/udp
|
|
--overlay-range the private network's range (default 10.42.0.0/16); refused
|
|
if it overlaps an interface or route the machine already has
|
|
|
|
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
|
|
permanent. The temporary one is called temp-mesh-controller and the permanent one is
|
|
called mesh-controller, 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.
|
|
Ports: bootstrap.DefaultPorts(),
|
|
OverlayRange: bootstrap.DefaultOverlayRange,
|
|
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])
|
|
}
|
|
if err := registryAgrees(set, &opts); err != nil {
|
|
return "", opts, false, err
|
|
}
|
|
return command, opts, jsonOut, nil
|
|
}
|
|
|
|
// registryAgrees makes --registry and --registry-port say one port (novox/hq ADR 0100): the
|
|
// registry is raised on the port the node gives it, and every node pulls from the address given.
|
|
// Either may be said alone and the other follows; said both ways, they must agree.
|
|
func registryAgrees(set *flag.FlagSet, opts *bootstrap.Options) error {
|
|
said := map[string]bool{}
|
|
set.Visit(func(f *flag.Flag) { said[f.Name] = true })
|
|
host, portText, err := net.SplitHostPort(opts.Registry)
|
|
if err != nil {
|
|
return fmt.Errorf("--registry %q is not host:port: %w", opts.Registry, err)
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil {
|
|
return fmt.Errorf("--registry %q does not end in a port", opts.Registry)
|
|
}
|
|
switch {
|
|
case said["registry-port"] && said["registry"] && port != opts.Ports.Registry:
|
|
return fmt.Errorf("--registry %s and --registry-port %d name two ports for one registry",
|
|
opts.Registry, opts.Ports.Registry)
|
|
case said["registry-port"]:
|
|
opts.Registry = net.JoinHostPort(host, strconv.Itoa(opts.Ports.Registry))
|
|
case said["registry"]:
|
|
opts.Ports.Registry = port
|
|
}
|
|
return 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 foundation 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, for the registry's and the builder's manifests and what phase two installs; without it this stops after the foundation")
|
|
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.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")
|
|
|
|
// Phase two — the installer goes as far as it can, and asks where a human must choose. A run
|
|
// without a terminal answers with these; a required choice nothing answered is a refusal.
|
|
set.StringVar(&opts.ToolsSource.Repository, "tools-source", opts.ToolsSource.Repository,
|
|
"the repository the shared base is built from")
|
|
set.StringVar(&opts.ToolsSource.Ref, "tools-ref", opts.ToolsSource.Ref,
|
|
"what of it to build (default main)")
|
|
set.StringVar(&opts.CatalogSource.Repository, "catalog-source", opts.CatalogSource.Repository,
|
|
"the catalogue REPOSITORY, for building its modules — --catalog is the checkout that says what they are")
|
|
set.StringVar(&opts.CatalogSource.Ref, "catalog-ref", opts.CatalogSource.Ref,
|
|
"what of it to build (default main)")
|
|
set.StringVar(&opts.SDKSource.Repository, "sdk-source", opts.SDKSource.Repository,
|
|
"the repository the shared library is built from, published before the base resolves it")
|
|
set.StringVar(&opts.SDKSource.Ref, "sdk-ref", opts.SDKSource.Ref,
|
|
"what of it to build (default main)")
|
|
set.StringVar(&opts.Site, "site", "main", "where this machine sits, for the private network")
|
|
|
|
// The foundation's ports are this node's (novox/hq ADR 0100): each is checked free before
|
|
// anything is raised, and becomes the node's setting for the module that binds it.
|
|
for _, p := range []struct {
|
|
name, what string
|
|
into *int
|
|
}{
|
|
{"store-port", "the store", &opts.Ports.Store},
|
|
{"bus-port", "the bus (amqps)", &opts.Ports.Bus},
|
|
{"amqp-port", "the broker's AMQP", &opts.Ports.AMQP},
|
|
{"management-port", "the broker's management, on loopback", &opts.Ports.Management},
|
|
{"registry-port", "the registry", &opts.Ports.Registry},
|
|
{"packages-port", "the package registry", &opts.Ports.Packages},
|
|
{"hub-port", "the private network's hub (udp)", &opts.Ports.Hub},
|
|
} {
|
|
set.IntVar(p.into, p.name, *p.into, "the machine's port for "+p.what)
|
|
}
|
|
set.StringVar(&opts.OverlayRange, "overlay-range", opts.OverlayRange,
|
|
"the private network's address range; must not overlap a tunnel the machine already runs")
|
|
if opts.Answers == nil {
|
|
opts.Answers = map[string]string{}
|
|
}
|
|
answers := opts.Answers
|
|
set.Func("private-network", "which private network to run (wireguard)", func(v string) error {
|
|
answers["private-network"] = v
|
|
return nil
|
|
})
|
|
set.Func("packet-filter", "which packet filter to run (nftables)", func(v string) error {
|
|
answers["packet-filter"] = v
|
|
return nil
|
|
})
|
|
set.Func("endpoint", "host:port other machines dial for the private network (derived from the broker address if unsaid)", func(v string) error {
|
|
answers["endpoint"] = v
|
|
return nil
|
|
})
|
|
set.Func("extras", "catalogue modules beyond the floor, comma-separated", func(v string) error {
|
|
for _, e := range strings.Split(v, ",") {
|
|
if e = strings.TrimSpace(e); e != "" {
|
|
opts.Extras = append(opts.Extras, e)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return set
|
|
}
|
|
|
|
// askOn is how a person is asked a choice, when there is a person: the question, the options, a
|
|
// read line. Wired only when stdin is a terminal, so the lab and unattended runs are never left
|
|
// waiting on a prompt nobody will answer.
|
|
func askOn(in *bufio.Reader, out io.Writer) func(bootstrap.Choice) (string, error) {
|
|
return func(c bootstrap.Choice) (string, error) {
|
|
fmt.Fprintf(out, "\n%s\n", c.Question)
|
|
if len(c.Options) > 0 {
|
|
fmt.Fprintf(out, " options: %s\n", strings.Join(c.Options, ", "))
|
|
}
|
|
if c.Default != "" {
|
|
fmt.Fprintf(out, " [%s] ", c.Default)
|
|
} else {
|
|
fmt.Fprint(out, " > ")
|
|
}
|
|
line, err := in.ReadString('\n')
|
|
if err != nil {
|
|
return "", fmt.Errorf("the terminal went away mid-question: %w", err)
|
|
}
|
|
return strings.TrimSpace(line), nil
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
// A person at a terminal is asked the choices; anything else answers with flags. `--json`
|
|
// counts as "anything else": a run whose output is being parsed has no one reading a
|
|
// question.
|
|
if info, err := os.Stdin.Stat(); err == nil &&
|
|
info.Mode()&os.ModeCharDevice != 0 && !jsonOut {
|
|
opts.Prompt = askOn(bufio.NewReader(os.Stdin), os.Stdout)
|
|
}
|
|
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-controller'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()
|
|
}
|