The installer goes as far as it can, and asks where a human must choose
Twelve steps made a mesh that RUNS and then said "what remains is somebody else's". The seven things that turn it into a mesh that WORKS — the shared base, a database provider, the catalogue, the private network, the packet filter — were typed afterwards, which is how they went missing for weeks without anything complaining. Six more steps now: base, store, catalogue, network, filter, extras. Everything in them is module add, build, assign and push — the same verbs a person types, through the same commands, so the installer and an operator remain one act. Where a human must choose, the installer asks. A choice resolves in the order a person expects: the flag wins; a lone option answers itself ALOUD, because "it chose for me" and "there was nothing to choose" read identically afterwards unless one speaks; a terminal is asked; a default fills in; and a required choice nothing answered refuses naming its flag — a guessed packet filter is a machine somebody else configured. The filter is required, so the question is which, not whether. A run without a terminal (the lab, --json) is never left waiting on a prompt nobody will answer. Placement is part of the network step, not a separate act — a lesson paid for: the module installed, the names file was written with no names in it, and everything reported success because nobody had said where the machine IS. The hub endpoint derives from the broker address when unsaid: the host other machines dial is one fact, not two that drift. Extras fail the run rather than soft-fail: somebody asked for them by name, and a mesh reporting success minus one thing is reporting the wrong thing. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -28,9 +28,11 @@ import (
|
||||
"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
|
||||
@@ -50,7 +52,7 @@ const (
|
||||
|
||||
const usage = `mesh-bootstrap — make a bare machine into a mesh
|
||||
|
||||
bootstrap the twelve steps below (the default)
|
||||
bootstrap the eighteen steps below (the default)
|
||||
version
|
||||
|
||||
1 preflight what has to be true before anything is changed
|
||||
@@ -67,6 +69,20 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
||||
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
|
||||
substrate'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 substrate template to build this machine's bundle from
|
||||
(default ` + defaultTemplate + `)
|
||||
--out where the produced bundle is written, for a person to read
|
||||
@@ -96,6 +112,18 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
||||
--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)
|
||||
--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 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.
|
||||
@@ -209,9 +237,67 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
|
||||
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.Site, "site", "main", "where this machine sits, for the private network")
|
||||
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":
|
||||
@@ -220,6 +306,13 @@ func run(ctx context.Context, command string, opts bootstrap.Options, jsonOut bo
|
||||
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,
|
||||
|
||||
@@ -108,6 +108,8 @@ func TestTheUsageTextAndTheFlagsAgree(t *testing.T) {
|
||||
for _, promised := range []string{
|
||||
"bundle", "out", "state", "system", "timeout", "wait", "dry-run", "json",
|
||||
"catalog", "node", "registry", "host", "host-service", "host-in-background",
|
||||
"tools-source", "tools-ref", "catalog-source", "catalog-ref",
|
||||
"private-network", "endpoint", "site", "packet-filter", "extras",
|
||||
} {
|
||||
if !declared[promised] {
|
||||
t.Errorf("the usage text promises --%s and no such flag exists", promised)
|
||||
|
||||
Reference in New Issue
Block a user