apply: tier 0 consumes a declaration and converges this machine
Stage 2 begins. The host stops only reporting and starts doing its one job (ADR 0037): take an ordered list of typed resources and make the machine match it, from a local file, with no mesh present. What lands in this slice — the network-free vocabulary ADR 0043 names first: - Parse: JSON, refused WHOLE on an unknown version, type, field, a missing id/type/path, or a duplicate id. An older host cannot be handed a newer vocabulary and do half of it. - directory and file appliers, each reading back after it writes — mode and owner asserted against the machine, content compared byte for byte. A value that did not take is a failed apply, not a success. - store: the applied-state record, authoritative while disconnected, written atomically. It is what makes removal possible. - Convergence: apply in the stated order (the host never reorders), record each success AFTER it works (ADR 0035), and remove what was applied before and is no longer declared — in reverse order, so a file goes before the directory that held it. - The data-loss guard: the host removes ONLY what it created, never what it adopted, and a created directory that now holds data is refused (os.Remove, never RemoveAll) rather than deleted (ADR 0018, 0030). created is sticky across re-applies — caught by running the real binary, not just the unit tests: recomputing it from disk made a re-applied resource look adopted and leak on the next drop. - Addressing: a declaration for another node is refused; a host with no identity yet applies its bundle (the first-node path). Not yet: sealed secrets, and the types that need the network or a runtime (container, package, network, service, archive, user, action) — they follow, and until then the host refuses them rather than doing part of a declaration. CLI: mesh-host apply [--store P] FILE. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
+75
-14
@@ -17,6 +17,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/inventory"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
)
|
||||
@@ -25,16 +26,25 @@ import (
|
||||
// defaulted to something that looks like a release.
|
||||
var version = "development build"
|
||||
|
||||
// defaultStore is where the host records what it has applied — authoritative while disconnected
|
||||
// (novox/hq 05-the-node-host.md). Under /var/lib because it outlives any single apply.
|
||||
const defaultStore = "/var/lib/mesh-host/store.json"
|
||||
|
||||
const usage = `mesh-host — the node host
|
||||
|
||||
profile what this machine can be asked to do
|
||||
inventory what this machine is, and what it holds
|
||||
profile what this machine can be asked to do
|
||||
inventory what this machine is, and what it holds
|
||||
apply [--store P] FILE
|
||||
make this machine match the declaration in FILE
|
||||
version
|
||||
|
||||
--json machine-readable output
|
||||
--timeout how long any single probe may take (default 10s)
|
||||
--json machine-readable output
|
||||
--timeout how long any single probe may take (default 10s)
|
||||
--store where the applied-state store lives (apply; default ` + defaultStore + `)
|
||||
|
||||
Stage 1: reports only. It applies nothing, connects to nothing, listens on nothing.
|
||||
profile and inventory report; apply changes this machine, and only within its own footprint —
|
||||
it removes what it once applied and no longer sees declared, and never touches what it did not
|
||||
create. Put --store before FILE.
|
||||
`
|
||||
|
||||
func main() {
|
||||
@@ -45,7 +55,7 @@ func main() {
|
||||
|
||||
command, opts, err := parseArgs(os.Args[1:])
|
||||
if err == nil {
|
||||
err = run(ctx, command, opts.json, opts.timeout)
|
||||
err = run(ctx, command, opts)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: %v\n", err)
|
||||
@@ -56,6 +66,8 @@ func main() {
|
||||
type options struct {
|
||||
json bool
|
||||
timeout time.Duration
|
||||
store string
|
||||
file string
|
||||
}
|
||||
|
||||
// parseArgs takes the subcommand first, then its flags.
|
||||
@@ -78,36 +90,47 @@ func parseArgs(args []string) (string, options, error) {
|
||||
set.Usage = func() { fmt.Fprint(os.Stderr, usage) }
|
||||
set.BoolVar(&opts.json, "json", false, "machine-readable output")
|
||||
set.DurationVar(&opts.timeout, "timeout", opts.timeout, "how long any single probe may take")
|
||||
set.StringVar(&opts.store, "store", defaultStore, "where the applied-state store lives")
|
||||
|
||||
if err := set.Parse(args); err != nil {
|
||||
return "", opts, err
|
||||
}
|
||||
// Anything left over was neither the command nor a flag. Refused rather than ignored: a
|
||||
// mistyped argument that changes nothing and reports success is worse than an error.
|
||||
if rest := set.Args(); len(rest) > 0 {
|
||||
// Anything left over was neither the command nor a flag. apply takes exactly one positional —
|
||||
// the declaration file; every other command takes none. A mistyped argument that changes
|
||||
// nothing and reports success is worse than an error, so leftovers are refused, not ignored.
|
||||
rest := set.Args()
|
||||
if command == "apply" {
|
||||
if len(rest) != 1 {
|
||||
return "", opts, fmt.Errorf("apply needs exactly one declaration file (put --store before it)")
|
||||
}
|
||||
opts.file = rest[0]
|
||||
} else if len(rest) > 0 {
|
||||
return "", opts, fmt.Errorf("unexpected argument %q — try `mesh-host help`", rest[0])
|
||||
}
|
||||
return command, opts, nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, command string, jsonOut bool, timeout time.Duration) error {
|
||||
func run(ctx context.Context, command string, opts options) error {
|
||||
switch command {
|
||||
case "profile":
|
||||
p := profile.Detect(ctx, profile.Default(nil), timeout)
|
||||
if jsonOut {
|
||||
p := profile.Detect(ctx, profile.Default(nil), opts.timeout)
|
||||
if opts.json {
|
||||
return writeJSON(p)
|
||||
}
|
||||
writeProfile(p)
|
||||
return nil
|
||||
|
||||
case "inventory":
|
||||
inv := inventory.Collect(ctx, nil, profile.Default(nil), timeout)
|
||||
if jsonOut {
|
||||
inv := inventory.Collect(ctx, nil, profile.Default(nil), opts.timeout)
|
||||
if opts.json {
|
||||
return writeJSON(inv)
|
||||
}
|
||||
writeInventory(inv)
|
||||
return nil
|
||||
|
||||
case "apply":
|
||||
return runApply(opts)
|
||||
|
||||
case "version":
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
@@ -121,6 +144,44 @@ func run(ctx context.Context, command string, jsonOut bool, timeout time.Duratio
|
||||
}
|
||||
}
|
||||
|
||||
// runApply reads a declaration from a file and makes this machine match it.
|
||||
//
|
||||
// Identity is empty here: stage 1 has no link, so a node has no name yet and applies whatever it
|
||||
// is handed — the first-node path (ADR 0043). When the link arrives, the node's identity is read
|
||||
// from the store and passed through, and a declaration addressed elsewhere is refused.
|
||||
func runApply(opts options) error {
|
||||
if opts.file == "" {
|
||||
return fmt.Errorf("apply needs a declaration file")
|
||||
}
|
||||
raw, err := os.ReadFile(opts.file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading declaration: %w", err)
|
||||
}
|
||||
decl, err := apply.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := apply.LoadStore(opts.store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := apply.Apply(decl, "", store, apply.Appliers())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.json {
|
||||
return writeJSON(res)
|
||||
}
|
||||
for _, r := range res.Applied {
|
||||
fmt.Printf(" applied %-10s %s\n", r.Type, r.Path)
|
||||
}
|
||||
for _, r := range res.Removed {
|
||||
fmt.Printf(" removed %-10s %s\n", r.Type, r.Path)
|
||||
}
|
||||
fmt.Printf("\n%d applied, %d removed\n", len(res.Applied), len(res.Removed))
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
Reference in New Issue
Block a user