The legacy key form placement.<node> is gone from the client, so the readers and their fixtures lose it. The check noplacement now also refuses the assigner's retired names in a manifest. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
456 lines
16 KiB
Go
456 lines
16 KiB
Go
package main
|
|
|
|
// What the mesh says about its own tools and machines, as the agent's instructions and the guard on its
|
|
// shell need it (novox/hq ADR 0245): every seat verb and module tool that says what shell command it
|
|
// replaces, and the mesh's machines by every name and address a session could reach one by.
|
|
//
|
|
// Read from the bus at start and every few minutes (issue 1126 B13; the controller that answered this before is
|
|
// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-contracts), the modules'
|
|
// own tools from the manifests the indexer keeps (`mesh-delivery-indexer.modules` and `.manifests`), and the
|
|
// machines and where each module runs from the seat mesh-module-assigner's `assignments`. Kept in the module's state so
|
|
// a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb
|
|
// that gains a `replaces` is in the next render's table and the guard's next refusal.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
|
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
|
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
|
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
|
)
|
|
|
|
// Replacement is one verb or tool and the shell commands it is the mesh's way to do.
|
|
type Replacement struct {
|
|
// Address is how it is called: `<seat>.<verb>`, or `<module>.<tool>`; NodeScoped says a machine goes
|
|
// before it, `<node>/`.
|
|
Address string `json:"address"`
|
|
NodeScoped bool `json:"node_scoped,omitempty"`
|
|
Seat bool `json:"seat,omitempty"`
|
|
Does string `json:"does,omitempty"`
|
|
Replaces []string `json:"replaces"`
|
|
// On is where a module's tool runs; empty for a seat's verb, which every holder serves.
|
|
On []string `json:"on,omitempty"`
|
|
}
|
|
|
|
// Machine is one of the mesh's machines and the names and addresses it is reached by.
|
|
type Machine struct {
|
|
Name string `json:"name"`
|
|
Domains []string `json:"domains,omitempty"`
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
}
|
|
|
|
// MeshTools is what the bus said, as kept in the module's state.
|
|
type MeshTools struct {
|
|
Replacements []Replacement `json:"replacements"`
|
|
Machines []Machine `json:"machines"`
|
|
// Asked is when the bus was last read whole.
|
|
Asked string `json:"asked,omitempty"`
|
|
// Refused is how often the guard here named each tool instead of a work-around, from its record: the
|
|
// table's order. Never kept — read from the record at each render.
|
|
Refused map[string]int `json:"-"`
|
|
}
|
|
|
|
// RefusedCounts are how often the guard's record named each tool, over its last lines.
|
|
func RefusedCounts(log string) map[string]int {
|
|
out := map[string]int{}
|
|
for _, e := range ReadGuardLog(log, 2000) {
|
|
for _, t := range e.Tools {
|
|
out[t]++
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// InternalSuffix is the mesh's own names' domain: every machine and service is `<name>.internal`
|
|
// (novox/hq ADR 0194), and a session reaches none of them by ssh.
|
|
const InternalSuffix = ".internal"
|
|
|
|
func (p Paths) meshTools() string { return filepath.Join(p.State, "mesh-tools.json") }
|
|
|
|
// ReadMeshTools is what was kept, or nil.
|
|
func ReadMeshTools(p Paths) *MeshTools {
|
|
var m MeshTools
|
|
if !readJSON(p.meshTools(), &m) {
|
|
return nil
|
|
}
|
|
return &m
|
|
}
|
|
|
|
// The states read, each declared in the manifest's reads.
|
|
const (
|
|
AssignmentsRead = (assignerModule + "." + mesh.StateAssignments)
|
|
ModulesRead = indexerModule + "." + catalog.StateModules
|
|
ManifestsRead = indexerModule + "." + catalog.StateManifests
|
|
)
|
|
|
|
// States reads the module states the manifest declares in its reads: a state's keys, and one key's value.
|
|
type States interface {
|
|
Keys(read string) ([]string, error)
|
|
Get(read, key string) ([]byte, bool, error)
|
|
}
|
|
|
|
// ModuleTools is one module's own tools that replace a command, as its manifest says (`replaces`), and where it runs.
|
|
type ModuleTools struct {
|
|
Module string
|
|
On []string
|
|
Replaces map[string][]string
|
|
}
|
|
|
|
// assignments is every node's assignments, from the seat mesh-module-assigner's state.
|
|
func assignments(st States) (map[string]mesh.Assignments, error) {
|
|
keys, err := st.Keys(AssignmentsRead)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", AssignmentsRead, err)
|
|
}
|
|
read := assignerclient.NewKeys()
|
|
for _, k := range keys {
|
|
v, found, err := st.Get(AssignmentsRead, k)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: %w", AssignmentsRead, k, err)
|
|
}
|
|
if found {
|
|
_, _ = read.Put(k, v, 0, false) // a key that does not read places nothing
|
|
}
|
|
}
|
|
return read.All(), nil
|
|
}
|
|
|
|
// runsOn is, by module, the nodes assignments puts it on, sorted.
|
|
func runsOn(placed map[string]mesh.Assignments) map[string][]string {
|
|
out := map[string][]string{}
|
|
for node, p := range placed {
|
|
for _, m := range p.Modules {
|
|
out[m.Module] = append(out[m.Module], node)
|
|
}
|
|
}
|
|
for _, nodes := range out {
|
|
sort.Strings(nodes)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// NodesRunning is the nodes assignments puts module on.
|
|
func NodesRunning(st States, module string) ([]string, error) {
|
|
placed, err := assignments(st)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return runsOn(placed)[module], nil
|
|
}
|
|
|
|
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest the indexer
|
|
// keeps of it. A module whose manifest cannot be read is left out and said in the error.
|
|
func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
|
|
var out []ModuleTools
|
|
var problems []error
|
|
modules := make([]string, 0, len(on))
|
|
for m := range on {
|
|
modules = append(modules, m)
|
|
}
|
|
sort.Strings(modules)
|
|
for _, module := range modules {
|
|
key, err := catalogclient.ModulesKey(module)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
raw, found, err := st.Get(ModulesRead, key)
|
|
if err != nil || !found {
|
|
if err != nil {
|
|
problems = append(problems, fmt.Errorf("%s %s: %w", ModulesRead, module, err))
|
|
}
|
|
continue
|
|
}
|
|
m, err := catalogclient.DecodeModule(raw)
|
|
if err != nil || m.Manifest == "" {
|
|
continue
|
|
}
|
|
mkey, err := catalogclient.ManifestsKey(m.Manifest)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
raw, found, err = st.Get(ManifestsRead, mkey)
|
|
if err != nil || !found {
|
|
if err != nil {
|
|
problems = append(problems, fmt.Errorf("%s of %s: %w", ManifestsRead, module, err))
|
|
}
|
|
continue
|
|
}
|
|
kept, err := catalogclient.DecodeManifest(raw)
|
|
if err != nil {
|
|
problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err))
|
|
continue
|
|
}
|
|
var man struct {
|
|
Replaces map[string][]string `json:"replaces"`
|
|
}
|
|
if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces})
|
|
}
|
|
return out, errors.Join(problems...)
|
|
}
|
|
|
|
// firstSentence is what a verb does, short: its description to the first full stop, at most 70 characters.
|
|
func firstSentence(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.Index(s, ". "); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
s = strings.TrimSuffix(s, ".")
|
|
if i := strings.IndexAny(s, ":;("); i > 20 {
|
|
s = strings.TrimSpace(s[:i])
|
|
}
|
|
if r := []rune(s); len(r) > 70 {
|
|
s = strings.TrimSpace(string(r[:69])) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the
|
|
// seats' order, then the modules' own tools by module and tool.
|
|
func ReplacementsOf(defined []seatdef.Definition, modules []ModuleTools) []Replacement {
|
|
var out []Replacement
|
|
for _, s := range defined {
|
|
for _, v := range s.Verbs {
|
|
if len(v.Replaces) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, Replacement{Address: s.Seat + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true,
|
|
Does: firstSentence(v.Description), Replaces: v.Replaces})
|
|
}
|
|
}
|
|
sorted := append([]ModuleTools(nil), modules...)
|
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Module < sorted[j].Module })
|
|
for _, m := range sorted {
|
|
names := make([]string, 0, len(m.Replaces))
|
|
for t := range m.Replaces {
|
|
names = append(names, t)
|
|
}
|
|
sort.Strings(names)
|
|
for _, t := range names {
|
|
if len(m.Replaces[t]) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, Replacement{Address: m.Module + "." + t, NodeScoped: true, Replaces: m.Replaces[t], On: m.On,
|
|
Does: strings.ReplaceAll(strings.TrimPrefix(t, strings.ReplaceAll(m.Module, "-", "_")+"_"), "_", " ")})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// lookup resolves one name, briefly: a name that does not resolve here is no address.
|
|
func lookup(name string) []string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
addrs, _ := net.DefaultResolver.LookupHost(ctx, name)
|
|
return addrs
|
|
}
|
|
|
|
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names
|
|
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
|
|
func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
|
|
var m MeshTools
|
|
placed, err := assignments(st)
|
|
if err != nil {
|
|
m.Replacements = ReplacementsOf(defined, nil)
|
|
return m, err
|
|
}
|
|
modules, merr := modulesTools(st, runsOn(placed))
|
|
m.Replacements = ReplacementsOf(defined, modules)
|
|
for node, p := range placed {
|
|
machine := Machine{Name: node, Domains: []string{node + InternalSuffix}}
|
|
if d := strings.ToLower(strings.TrimSpace(p.Decided.PublicDomain)); d != "" {
|
|
machine.Domains = append(machine.Domains, d)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, name := range append([]string{node}, machine.Domains...) {
|
|
for _, a := range resolve(name) {
|
|
if !seen[a] && !net.ParseIP(a).IsLoopback() {
|
|
seen[a] = true
|
|
machine.Addresses = append(machine.Addresses, a)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(machine.Addresses)
|
|
m.Machines = append(m.Machines, machine)
|
|
}
|
|
sort.Slice(m.Machines, func(i, j int) bool { return m.Machines[i].Name < m.Machines[j].Name })
|
|
return m, merr
|
|
}
|
|
|
|
// KeepMeshTools writes what was asked to the module's state when it differs from what was kept, and
|
|
// answers whether it did. When was asked is not a difference.
|
|
func KeepMeshTools(p Paths, m MeshTools) bool {
|
|
if was := ReadMeshTools(p); was != nil {
|
|
a, b := *was, m
|
|
a.Asked, b.Asked = "", ""
|
|
x, _ := json.Marshal(a)
|
|
y, _ := json.Marshal(b)
|
|
if string(x) == string(y) {
|
|
return false
|
|
}
|
|
}
|
|
m.Asked = time.Now().UTC().Format(time.RFC3339)
|
|
raw, _ := indented(m)
|
|
tmp := p.meshTools() + ".tmp"
|
|
if os.WriteFile(tmp, raw, 0o600) != nil {
|
|
return false
|
|
}
|
|
return os.Rename(tmp, p.meshTools()) == nil
|
|
}
|
|
|
|
// GuardDataOf is what the guard judges with on this machine.
|
|
func GuardDataOf(p Paths, m *MeshTools) GuardData {
|
|
d := GuardData{Node: p.Node, Log: filepath.Join(p.State, "guard.log"), Machines: []Machine{}, Replacements: []Replacement{}}
|
|
if p.Confined() {
|
|
// The session's account cannot write the module's state, and must not rewrite its own record (ADR 0266).
|
|
d.Agent, d.Log = p.Agent, JournalRecord+GuardJournalID
|
|
}
|
|
if m != nil {
|
|
d.Machines, d.Replacements = m.Machines, m.Replacements
|
|
}
|
|
return d
|
|
}
|
|
|
|
// guardFiles is the guard to place: this module's own executable and what it judges with. None where the
|
|
// executable cannot be named — then no hook is written, rather than one that cannot run.
|
|
func guardFiles(p Paths, m *MeshTools) *GuardFiles {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
|
exe = resolved
|
|
}
|
|
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
|
|
}
|
|
|
|
// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the assignments are read again
|
|
// when their followed states change (a views event), not on this timer.
|
|
const MeshToolsEvery = 10 * time.Minute
|
|
|
|
// MeshToolsFirst is how long the first read waits for the first views to be answered.
|
|
const MeshToolsFirst = 2 * time.Minute
|
|
|
|
// refreshMeshTools reads the pictures once the first views are answered, and again whenever one changes (and the
|
|
// machines' addresses every few minutes), and renders when what it said changed.
|
|
func refreshMeshTools(p Paths) {
|
|
select {
|
|
case <-meshViews.Ready():
|
|
case <-time.After(MeshToolsFirst):
|
|
say("the first views of the tools and the assignments were not answered in %s; reading what is held", MeshToolsFirst)
|
|
}
|
|
startSeatList(seatlist.StdioBus())
|
|
for {
|
|
m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup)
|
|
if err != nil {
|
|
say("reading what each tool replaces and which machines the mesh has: %v", err)
|
|
}
|
|
if (err == nil || len(m.Replacements) > 0) && KeepMeshTools(p, m) {
|
|
if _, err := RenderNow(p, writeManaged); err != nil {
|
|
say("rendering what the bus said of the tools: %v", err)
|
|
} else {
|
|
say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines))
|
|
}
|
|
}
|
|
select {
|
|
case <-meshChanged:
|
|
time.Sleep(2 * time.Second) // a burst of events is one read
|
|
select {
|
|
case <-meshChanged:
|
|
default:
|
|
}
|
|
case <-time.After(MeshToolsEvery):
|
|
}
|
|
}
|
|
}
|
|
|
|
// GuardStatus is what claude_code_guard answers.
|
|
func GuardStatus(p Paths, last int) map[string]any {
|
|
m := ReadMeshTools(p)
|
|
d := GuardDataOf(p, m)
|
|
asked := "never: the bus has not been read whole yet"
|
|
if m != nil {
|
|
asked = m.Asked
|
|
}
|
|
return map[string]any{
|
|
"refuses": []string{
|
|
"ssh, scp, sftp, rsync, mosh or autossh to a mesh machine: its name, a name under its domains, any *" + InternalSuffix +
|
|
" name or one of its addresses, read as ssh reads it (ssh -G); a jump through one too. An ssh login as `" +
|
|
ForgeUser + "` is the forge's account and passes",
|
|
"writing /etc/hosts or /etc/resolv.conf, by the shell or the agent's Edit and Write",
|
|
"HOSTALIASES, named anywhere in a command",
|
|
"any command naming " + OverrideVar,
|
|
"where the machine names an agent account of its own (novox/hq ADR 0266): every command and file edit of a " +
|
|
"session that does not run as it, whatever it is, and no override lifts it",
|
|
},
|
|
"agent_account": map[bool]string{true: p.Agent, false: "none of its own: agents run as the operator's account"}[p.Confined()],
|
|
"override": OverrideVar + "=<why>, set in the operator's own shell before the session starts; read from the session's " +
|
|
"environment as it was started, recorded with why, and not honoured when it cannot be recorded",
|
|
"hook": GuardCommand(),
|
|
"asked": asked,
|
|
"machines": d.Machines,
|
|
"replacements": d.Replacements,
|
|
"instead_of": InsteadOf(m),
|
|
"record": ReadGuardLog(d.Log, last),
|
|
}
|
|
}
|
|
|
|
// seatList is the seat list this module holds: the definitions of the seats, read from the seat mesh-catalog and
|
|
// followed on its events (novox/hq ADR 0635 §1, ADR 0636), the foundation's bootstrapped ones until it answers.
|
|
var seatList *seatlist.Reader
|
|
|
|
// startSeatList starts the reader once the runtime serves; a change of the list is told like any followed state, so the
|
|
// instructions' table is read again.
|
|
func startSeatList(bus seatlist.Bus) {
|
|
r, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(),
|
|
OnChange: func([]catalogue.Seat) {
|
|
select {
|
|
case meshChanged <- struct{}{}:
|
|
default:
|
|
}
|
|
}})
|
|
if err != nil {
|
|
say("following the seat list: %v", err)
|
|
}
|
|
seatListMu.Lock()
|
|
seatList = r
|
|
seatListMu.Unlock()
|
|
}
|
|
|
|
var seatListMu sync.Mutex
|
|
|
|
// definedSeats are the held definitions in reading order, mesh seats first, then node seats, each by name; empty until
|
|
// the reader has started.
|
|
func definedSeats() []seatdef.Definition {
|
|
seatListMu.Lock()
|
|
r := seatList
|
|
seatListMu.Unlock()
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
held := r.Definitions()
|
|
var out []seatdef.Definition
|
|
for _, s := range r.Seats() {
|
|
if d, ok := held[s.Name]; ok {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|