Delete internal/catalogue: mesh-sdk's go/catalogue is the one catalogue package (issue 1381)
Nothing in this repository imports it any more; go/catalogue in mesh-sdk 14fd5cb68fd1 is a copy of it (R4a's review diffed it against 6c2a8d62). The tests that read this repository's own files live on in internal/ownmanifest.
This commit is contained in:
@@ -1,242 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What an adopted node is declared in place of a filter (novox/hq ADR 0100).
|
|
||||||
//
|
|
||||||
// On an adopted node the firewall found on the machine stays in force: the mesh loads no table
|
|
||||||
// that drops by default or holds an accept. What the mesh needs reachable is declared as
|
|
||||||
// openings, which the host converges through the found firewall in its own terms; and the mesh
|
|
||||||
// guards its own foundation ports itself, in a table that only refuses.
|
|
||||||
|
|
||||||
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
|
|
||||||
// never a module's, so none of it is ever held as found.
|
|
||||||
const AdoptionPrefix = "adoption."
|
|
||||||
|
|
||||||
// Where an opening admits from, on the wire.
|
|
||||||
const (
|
|
||||||
OpeningFromEverywhere = "everywhere"
|
|
||||||
OpeningFromMesh = "mesh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The two paths a packet reaches a port by: received by the machine, or forwarded to a
|
|
||||||
// container that publishes it.
|
|
||||||
const (
|
|
||||||
PathIncoming = "incoming"
|
|
||||||
PathForwarded = "forwarded"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Guard resources: the refusal-only table, the unit that loads it, and that unit running.
|
|
||||||
const (
|
|
||||||
GuardPath = "/etc/mesh/guard.nft"
|
|
||||||
GuardUnit = "mesh-guard.service"
|
|
||||||
// GuardUnitPath is where the unit is written.
|
|
||||||
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
|
|
||||||
)
|
|
||||||
|
|
||||||
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
|
|
||||||
// raises the same three on an adopted genesis, so the first push finds them already there.
|
|
||||||
func GuardID() string { return AdoptionPrefix + "guard" }
|
|
||||||
func GuardUnitID() string { return AdoptionPrefix + "guard-unit" }
|
|
||||||
func GuardRunningID() string { return AdoptionPrefix + "guard-running" }
|
|
||||||
|
|
||||||
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
|
|
||||||
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
|
|
||||||
func GuardPackageID() string { return AdoptionPrefix + "guard-package" }
|
|
||||||
|
|
||||||
// GuardPackage is the package that carries nft.
|
|
||||||
const GuardPackage = "nftables"
|
|
||||||
|
|
||||||
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
|
|
||||||
func OpeningID(protocol string, port int, path string) string {
|
|
||||||
return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
|
|
||||||
// filter it would load were the node converged, each from where that filter would admit it.
|
|
||||||
//
|
|
||||||
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
|
|
||||||
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
|
|
||||||
// only opens nothing. `published` maps a machine port a container publishes to the container's
|
|
||||||
// port: a published port is forwarded, not received, so its opening names the forwarded path and
|
|
||||||
// the port the packet is forwarded to.
|
|
||||||
func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any {
|
|
||||||
type key struct {
|
|
||||||
protocol string
|
|
||||||
port int
|
|
||||||
}
|
|
||||||
from := map[key]string{}
|
|
||||||
var order []key
|
|
||||||
widen := func(k key, f string) {
|
|
||||||
was, seen := from[k]
|
|
||||||
if !seen {
|
|
||||||
order = append(order, k)
|
|
||||||
}
|
|
||||||
if !seen || was != OpeningFromEverywhere {
|
|
||||||
from[k] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rule := range rules {
|
|
||||||
switch rule.From {
|
|
||||||
case FromEverywhere:
|
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
|
||||||
case FromMesh:
|
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
|
||||||
case FromLocalNetwork:
|
|
||||||
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
|
|
||||||
// stays, and it is what admits the machine's local network; an opening has no word for a
|
|
||||||
// local network, and widening it to everywhere would open the port to the internet.
|
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, port := range foundation {
|
|
||||||
widen(key{"tcp", port}, OpeningFromEverywhere)
|
|
||||||
}
|
|
||||||
sort.Slice(order, func(a, b int) bool {
|
|
||||||
if order[a].port != order[b].port {
|
|
||||||
return order[a].port < order[b].port
|
|
||||||
}
|
|
||||||
return order[a].protocol < order[b].protocol
|
|
||||||
})
|
|
||||||
out := make([]map[string]any, 0, len(order))
|
|
||||||
for _, k := range order {
|
|
||||||
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
|
|
||||||
"from": from[k]}
|
|
||||||
if to, forwarded := published[k.protocol][k.port]; forwarded {
|
|
||||||
opening["id"] = OpeningID(k.protocol, k.port, PathForwarded)
|
|
||||||
opening["path"] = PathForwarded
|
|
||||||
opening["to"] = to
|
|
||||||
} else {
|
|
||||||
opening["id"] = OpeningID(k.protocol, k.port, PathIncoming)
|
|
||||||
opening["path"] = PathIncoming
|
|
||||||
}
|
|
||||||
out = append(out, opening)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Published is every port the given containers publish on the machine, by protocol and machine
|
|
||||||
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
|
|
||||||
// the machine reaches it, forwarded or not.
|
|
||||||
func Published(resources []map[string]any) map[string]map[int]int {
|
|
||||||
out := map[string]map[int]int{}
|
|
||||||
for _, r := range resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "container" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
listed, _ := r["ports"].([]any)
|
|
||||||
for _, entry := range listed {
|
|
||||||
written := strings.TrimSpace(fmt.Sprint(entry))
|
|
||||||
protocol := "tcp"
|
|
||||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
|
||||||
protocol = written[cut+1:]
|
|
||||||
}
|
|
||||||
// Indexed from the end, so an IPv6 address's own colons never shift the ports.
|
|
||||||
outer, inner, address, ok := mapping(written)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch strings.Trim(address, "[]") {
|
|
||||||
case "127.0.0.1", "localhost", "::1":
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if out[protocol] == nil {
|
|
||||||
out[protocol] = map[int]int{}
|
|
||||||
}
|
|
||||||
out[protocol][outer] = inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AsGuard renders the mesh's refusal-only table for the given machine ports.
|
|
||||||
//
|
|
||||||
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
|
|
||||||
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
|
|
||||||
// touch it. It refuses only packets addressed to this machine, and the ports except from the
|
|
||||||
// machine itself — its loopback and the container runtime's own networks — and from the private
|
|
||||||
// network, known by the interface a packet arrives on and never by its source address. At
|
|
||||||
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
|
|
||||||
// was sent to; in the inet family, so both address families.
|
|
||||||
//
|
|
||||||
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
|
|
||||||
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
|
|
||||||
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
|
|
||||||
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
|
|
||||||
// would have its containers' traffic to a guarded port refused, which reads as the port being
|
|
||||||
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
|
|
||||||
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
|
|
||||||
// it means adding names here and in the installer's copy together, which the golden test holds to
|
|
||||||
// one text.
|
|
||||||
//
|
|
||||||
// The same text the installer raises on an adopted genesis; a test holds both to it.
|
|
||||||
func AsGuard(ports []int) string {
|
|
||||||
sorted := append([]int{}, ports...)
|
|
||||||
sort.Ints(sorted)
|
|
||||||
listed := make([]string, len(sorted))
|
|
||||||
for i, p := range sorted {
|
|
||||||
listed[i] = strconv.Itoa(p)
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("table inet mesh_guard {}\n")
|
|
||||||
b.WriteString("delete table inet mesh_guard\n")
|
|
||||||
b.WriteString("table inet mesh_guard {\n")
|
|
||||||
b.WriteString("\tchain prerouting {\n")
|
|
||||||
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
|
|
||||||
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
|
|
||||||
// say — is never the guard's business (novox/hq ADR 0103).
|
|
||||||
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
|
|
||||||
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
|
|
||||||
strings.Join(listed, ", "))
|
|
||||||
b.WriteString("\t}\n")
|
|
||||||
b.WriteString("}\n")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
|
|
||||||
// a flush, which would take the container runtime's rules and the found firewall with it.
|
|
||||||
func GuardUnitText() string {
|
|
||||||
return "[Unit]\n" +
|
|
||||||
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
|
|
||||||
// Early, before the network is up, and without the default dependencies that would
|
|
||||||
// order it after the network; stopped at shutdown like any unit.
|
|
||||||
"DefaultDependencies=no\n" +
|
|
||||||
"Wants=network-pre.target\n" +
|
|
||||||
"Before=network-pre.target shutdown.target\n" +
|
|
||||||
"Conflicts=shutdown.target\n" +
|
|
||||||
"\n" +
|
|
||||||
"[Service]\n" +
|
|
||||||
"Type=oneshot\n" +
|
|
||||||
"RemainAfterExit=yes\n" +
|
|
||||||
"ExecStart=nft -f " + GuardPath + "\n" +
|
|
||||||
"ExecReload=nft -f " + GuardPath + "\n" +
|
|
||||||
"ExecStop=nft delete table inet mesh_guard\n" +
|
|
||||||
"\n" +
|
|
||||||
"[Install]\n" +
|
|
||||||
"WantedBy=multi-user.target\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
|
|
||||||
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
|
|
||||||
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
|
|
||||||
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
|
|
||||||
// set is not a table nft loads.
|
|
||||||
func GuardResources(ports []int) []map[string]any {
|
|
||||||
if len(ports) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []map[string]any{
|
|
||||||
{"id": GuardPackageID(), "type": "package", "package": GuardPackage},
|
|
||||||
{"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports),
|
|
||||||
"mode": "0644"},
|
|
||||||
{"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(),
|
|
||||||
"mode": "0644"},
|
|
||||||
{"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running",
|
|
||||||
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh built, named by what it is rather than by where it was pushed.
|
|
||||||
//
|
|
||||||
// **An image is recorded by its digest and its path; the registry's address is a route to it**
|
|
||||||
// (novox/hq 04-ISSUES/102). A build used to be recorded as `<registry>:<port>/<module>/<artifact>@sha256:…`
|
|
||||||
// — the reference the builder pushed to, kept whole — and every declaration carried that literal.
|
|
||||||
// Move the registry's port, or the registry, and every fresh pull of a mesh image fails: a new
|
|
||||||
// node, a recreate after eviction. The digest is the identity; the address is the node's setting
|
|
||||||
// for the module that serves the artifact store, and it is read from there when a reference is
|
|
||||||
// composed, never written into a record.
|
|
||||||
//
|
|
||||||
// So a kept reference has a scheme of the mesh's own, named after the provision that answers it:
|
|
||||||
//
|
|
||||||
// artifact-store://<module>/<artifact>@sha256:<hex> an image
|
|
||||||
// artifact-store://<module>/<artifact>/blobs/sha256:<hex> an archive
|
|
||||||
//
|
|
||||||
// No runtime knows the scheme. That is the point of it being one: a reference that leaks to a
|
|
||||||
// machine uncomposed is refused by the runtime as malformed, in front of whoever sent it, rather
|
|
||||||
// than pulled from a public registry that happens to have a repository by that name — which is
|
|
||||||
// what an address-less `<module>/<artifact>@sha256:…` would be.
|
|
||||||
|
|
||||||
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
|
||||||
// the store's address.
|
|
||||||
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
|
||||||
|
|
||||||
// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote
|
|
||||||
// one, taken off.
|
|
||||||
//
|
|
||||||
// For a reference the builder announced — one it pushed to the store — which is the only kind
|
|
||||||
// this is called on. An image the builder named `<host>/<path>@sha256:…` is kept as its path; an
|
|
||||||
// archive it named `http://<host>/v2/<path>/blobs/<digest>` likewise. A reference with no address
|
|
||||||
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
|
|
||||||
// what it was.
|
|
||||||
func Recorded(reference string) string {
|
|
||||||
if strings.HasPrefix(reference, ArtifactStoreScheme) {
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
|
|
||||||
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
|
|
||||||
return ArtifactStoreScheme + path
|
|
||||||
}
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
host, path, ok := strings.Cut(reference, "/")
|
|
||||||
if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") {
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
return ArtifactStoreScheme + path
|
|
||||||
}
|
|
||||||
|
|
||||||
// isRegistryHost is the runtime's own rule for reading the first component of a reference as a
|
|
||||||
// registry rather than as a namespace: it has a dot or a port in it, or it is localhost.
|
|
||||||
func isRegistryHost(component string) bool {
|
|
||||||
return component == "localhost" || strings.ContainsAny(component, ".:")
|
|
||||||
}
|
|
||||||
|
|
||||||
// InArtifactStore reports whether a reference is a kept one, and what it names there.
|
|
||||||
func InArtifactStore(reference string) (path string, kept bool) {
|
|
||||||
return strings.CutPrefix(reference, ArtifactStoreScheme)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
|
|
||||||
// (host:port). A reference that is not a kept one is what it was.
|
|
||||||
func Routed(reference, address string) string {
|
|
||||||
path, kept := InArtifactStore(reference)
|
|
||||||
if !kept {
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
if strings.Contains(path, "/blobs/") {
|
|
||||||
return "http://" + address + "/v2/" + path
|
|
||||||
}
|
|
||||||
return address + "/" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rerouted is a reference the mesh recorded, whichever way it was recorded, as a machine fetches
|
|
||||||
// it now: a kept one composed with the store's address, and one recorded before references were
|
|
||||||
// kept without their address — the builder's own `<host>/<path>@sha256:…` — re-routed to where
|
|
||||||
// the store is now. Only for references that are the mesh's own: everything a build record
|
|
||||||
// holds is, by construction.
|
|
||||||
func Rerouted(reference, address string) string {
|
|
||||||
return Routed(Recorded(reference), address)
|
|
||||||
}
|
|
||||||
|
|
||||||
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
|
|
||||||
//
|
|
||||||
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
|
|
||||||
// routed through the store as this network reaches it now. A reference recorded with an address
|
|
||||||
// before references were kept without one is re-routed the same way — but only when the mesh
|
|
||||||
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
|
|
||||||
// image from a public registry under its own name and that one is exactly where it says.
|
|
||||||
//
|
|
||||||
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
|
|
||||||
// refuse the scheme on the machine, one push away from the reason.
|
|
||||||
//
|
|
||||||
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
|
|
||||||
// and the builder before the mesh exists, pushes them itself and pins their manifests to
|
|
||||||
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
|
|
||||||
// repositories with no build record, so `with.Built` does not name them and they are left as
|
|
||||||
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
|
|
||||||
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
|
|
||||||
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
|
|
||||||
// store (novox/hq 04-ISSUES/102, finding F4).
|
|
||||||
func artifactsInto(resource map[string]any, module string, with Rendering) error {
|
|
||||||
for _, key := range []string{"image", "source"} {
|
|
||||||
written, ok := resource[key].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, kept := InArtifactStore(written); kept {
|
|
||||||
if with.ArtifactStore == "" {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
|
|
||||||
"artifact store on its network to fetch it from — nothing assigned offers "+
|
|
||||||
"%s, or the machine offering it is not on the private network",
|
|
||||||
module, resource["id"], written, ArtifactStoreProvision)
|
|
||||||
}
|
|
||||||
resource[key] = Routed(written, with.ArtifactStore)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if with.ArtifactStore == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
recorded := Recorded(written)
|
|
||||||
if recorded == written {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path, _ := InArtifactStore(recorded)
|
|
||||||
repository := path
|
|
||||||
if at := strings.IndexAny(path, "@"); at >= 0 {
|
|
||||||
repository = path[:at]
|
|
||||||
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
|
|
||||||
repository = path[:blobs]
|
|
||||||
}
|
|
||||||
if with.Built[repository] {
|
|
||||||
resource[key] = Routed(recorded, with.ArtifactStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
|
|
||||||
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
|
|
||||||
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
|
|
||||||
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
|
|
||||||
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
|
|
||||||
|
|
||||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
|
||||||
const BackupNamespace = "backup"
|
|
||||||
|
|
||||||
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
|
|
||||||
// refused rather than written out as text.
|
|
||||||
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
|
|
||||||
|
|
||||||
// backupKinds is the contribution kind each key of the backup placement renders.
|
|
||||||
var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData}
|
|
||||||
|
|
||||||
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
|
|
||||||
type backupPlacer struct {
|
|
||||||
modules []Manifest
|
|
||||||
with Rendering
|
|
||||||
facts map[string]string
|
|
||||||
caps map[string]bool
|
|
||||||
unplaced *[]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// placed is the text of one key for a holder, or what is wrong with naming it.
|
|
||||||
func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) {
|
|
||||||
kind, known := backupKinds[key]
|
|
||||||
switch {
|
|
||||||
case item:
|
|
||||||
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
|
|
||||||
case !known:
|
|
||||||
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
|
|
||||||
case !holder.ClaimsSeat(BackupSeat):
|
|
||||||
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
|
|
||||||
"(novox/hq ADR 0212)", holder.Module, BackupSeat)
|
|
||||||
}
|
|
||||||
text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps)
|
|
||||||
if b.unplaced != nil {
|
|
||||||
*b.unplaced = append(*b.unplaced, left...)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", err.Error()
|
|
||||||
}
|
|
||||||
return text, ""
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232).
|
|
||||||
//
|
|
||||||
// **What a resolution chooses is not where a consumer's data is.** Resolving answers "which provider
|
|
||||||
// would I pick now", from the seats' holders, the pins and what is assigned where, and every one of
|
|
||||||
// those can change under a consumer without anybody meaning to move it. For a resolver that is the
|
|
||||||
// point: any provider answers alike. For a database it is the consumer's whole state: on 2026-10-05
|
|
||||||
// one change to how a seat's holder answers (issue 258) re-bound five database consumers on one
|
|
||||||
// machine to the store on another, each was given a fresh, empty database there, and nothing warned
|
|
||||||
// for twenty hours (issue 273). Nothing was lost only because the old provider kept everything.
|
|
||||||
//
|
|
||||||
// So the mesh records where each such consumer was last sent (the store's `binding` table), and a
|
|
||||||
// resolution that would answer it from anywhere else keeps the recorded provider instead and says so.
|
|
||||||
// **Only a pin moves it**, because a pin is a person: the one act that says "answer this machine's
|
|
||||||
// consumers from there", taken knowing the data must go first.
|
|
||||||
|
|
||||||
// KeptBinding is one consumer this resolution would have moved, and did not.
|
|
||||||
type KeptBinding struct {
|
|
||||||
// Machine is where the consumer runs, and Consumer the module there that is bound.
|
|
||||||
Machine string
|
|
||||||
Consumer string
|
|
||||||
// Provision is what it is bound for.
|
|
||||||
Provision string
|
|
||||||
// Bound is the provider it was recorded at, and still is; Would is the one the resolution chose.
|
|
||||||
Bound Chosen
|
|
||||||
Would Chosen
|
|
||||||
}
|
|
||||||
|
|
||||||
// String is the condition's sentence: the move, where the data is, and the act that confirms it.
|
|
||||||
func (k KeptBinding) String() string {
|
|
||||||
return fmt.Sprintf("would move %s's %s from %s to %s — its data is on %s; kept there. "+
|
|
||||||
"`pin %s %s %s %s` to confirm a move (and move the data first)",
|
|
||||||
k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// keepBound holds every need for a provision that keeps its consumers' data at the provider its
|
|
||||||
// consumer was bound to, where the resolution chose another.
|
|
||||||
//
|
|
||||||
// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
|
|
||||||
// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
|
|
||||||
// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
|
|
||||||
// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
|
|
||||||
// never answered by the provider chosen**: answering it there is exactly the silent move this exists
|
|
||||||
// to stop, and the consumer's data is still wherever it was.
|
|
||||||
func keepBound(needs []Needed, catalogue map[string]Manifest, node Node, world World,
|
|
||||||
keeps map[string]bool, here func(string) bool) ([]Needed, []KeptBinding, []string) {
|
|
||||||
var kept []KeptBinding
|
|
||||||
var problems []string
|
|
||||||
refused := map[[2]string]bool{}
|
|
||||||
out := make([]Needed, 0, len(needs))
|
|
||||||
for _, n := range needs {
|
|
||||||
if n.ByRecord || !keeps[n.Name] {
|
|
||||||
out = append(out, n)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n.KeepsData = true
|
|
||||||
bound, recorded := world.Bound[n.For][n.Name]
|
|
||||||
chose := Chosen{Node: n.From, Module: n.Module}
|
|
||||||
if !recorded || sameProvider(bound, chose) {
|
|
||||||
out = append(out, n)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if pin, pinned := world.Pinned[n.Name]; pinned && pin.matches(Provider{Node: chose.Node, Module: chose.Module}) {
|
|
||||||
out = append(out, n)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
|
|
||||||
if !ok {
|
|
||||||
if key := [2]string{n.For, n.Name}; !refused[key] {
|
|
||||||
refused[key] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
|
|
||||||
"which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
|
|
||||||
"%s back what it provided",
|
|
||||||
n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
|
|
||||||
bound.Node))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, held)
|
|
||||||
kept = append(kept, KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
|
|
||||||
}
|
|
||||||
return out, kept, problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
|
|
||||||
// is written without one, but a person's hand might) matches any module on its node.
|
|
||||||
func sameProvider(bound, chose Chosen) bool {
|
|
||||||
return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundNeed is the need answered by the recorded provider, or why it cannot be.
|
|
||||||
func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node, world World,
|
|
||||||
here func(string) bool) (Needed, bool, string) {
|
|
||||||
held := Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
|
|
||||||
if bound.Node == node.Name {
|
|
||||||
m, known := catalogue[bound.Module]
|
|
||||||
if !known || !here(bound.Module) || !providesAt(m, n.Name, ScopeMesh) {
|
|
||||||
return Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
|
|
||||||
}
|
|
||||||
at := node.At
|
|
||||||
if at == "" {
|
|
||||||
at = "127.0.0.1"
|
|
||||||
}
|
|
||||||
held.From, held.At, held.Module = node.Name, at, m.Module
|
|
||||||
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
|
|
||||||
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
|
|
||||||
return held, true, ""
|
|
||||||
}
|
|
||||||
matching := bound.among(world.Offered[n.Name])
|
|
||||||
if len(matching) != 1 {
|
|
||||||
return Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
|
|
||||||
}
|
|
||||||
p := matching[0]
|
|
||||||
if node.At == "" || p.At == "" {
|
|
||||||
return Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
|
|
||||||
}
|
|
||||||
identity := DefaultIdentityBound
|
|
||||||
if pm, known := catalogue[p.Module]; known {
|
|
||||||
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
|
|
||||||
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
|
|
||||||
identity = pm.IdentityBoundOf(n.Name)
|
|
||||||
}
|
|
||||||
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
|
|
||||||
held.LeftOut = p.LeftOut
|
|
||||||
return held, true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unbound is one consumer that still asks for a provision and whose own resolution binds it to
|
|
||||||
// another provider than the one a pair credential on record was made with (novox/hq issue 274).
|
|
||||||
//
|
|
||||||
// **A provider is granted exactly the consumers bound to it.** The credential from the old provider
|
|
||||||
// stays on record — it is the key to a login that provider keeps, disabled, with the consumer's data,
|
|
||||||
// until a person deletes it with `cleanup delete` (ADR 0230), and a pin back must find it — but it is
|
|
||||||
// no longer granted, so the provider stops being asked for it and retires it. Said on every plan and
|
|
||||||
// push of the provider, so a credential the mesh keeps and does not use is never kept silently.
|
|
||||||
type Unbound struct {
|
|
||||||
// Provision is what was required, Provider the machine the credential on record is from.
|
|
||||||
Provision string `json:"provision"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
// Consumer is the machine, Module the module on it that requires it, Local the credential's
|
|
||||||
// name inside it where it keeps several (ADR 0094).
|
|
||||||
Consumer string `json:"consumer"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
Local string `json:"local,omitempty"`
|
|
||||||
// BoundTo is every provider the consumer's resolution binds this credential to now; empty when
|
|
||||||
// it binds it nowhere — the module asks for the provision under other local names.
|
|
||||||
BoundTo []string `json:"bound_to,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u Unbound) String() string {
|
|
||||||
who := u.Module
|
|
||||||
if u.Local != "" {
|
|
||||||
who += " (as " + u.Local + ")"
|
|
||||||
}
|
|
||||||
now := "is bound to no provider under that name"
|
|
||||||
if len(u.BoundTo) > 0 {
|
|
||||||
now = "is bound to " + strings.Join(u.BoundTo, ", ")
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s on %s %s for %s, not to %s — %s no longer grants it, so it retires that "+
|
|
||||||
"login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+
|
|
||||||
"record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
|
|
||||||
// module that requires it and the credential's local name — answered by a machine rather than by a
|
|
||||||
// record. None means this machine states no such binding.
|
|
||||||
func (r Resolution) BindsFrom(provision, module, local string) []string {
|
|
||||||
var from []string
|
|
||||||
for _, n := range r.Needs {
|
|
||||||
if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !slices.Contains(from, n.From) {
|
|
||||||
from = append(from, n.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return from
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The half of a connection that is not secret, put where the program reading it can find it.
|
|
||||||
//
|
|
||||||
// **The asymmetry this removes was backwards** (novox/hq 04-ISSUES/023). A sealed credential can
|
|
||||||
// be placed inside any configuration file a module writes: the module leaves a hole, the mesh
|
|
||||||
// delivers the value sealed beside it, and the host — the only thing that sees both — fills it in.
|
|
||||||
// The host and the port and the name to present are ordinary facts the mesh holds in the clear,
|
|
||||||
// and they were the ones stuck: readable only inside a JSON binding, which a program reading
|
|
||||||
// `KEY=value` cannot use.
|
|
||||||
//
|
|
||||||
// So the same shape, and simpler. These values are not secret, so **the control plane substitutes
|
|
||||||
// them itself** before the declaration is sent. Nothing new reaches the host, which learns no
|
|
||||||
// formats and gains no fields.
|
|
||||||
//
|
|
||||||
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
|
||||||
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
|
|
||||||
// facts about any provision at all, and everything else comes from what the provider said it
|
|
||||||
// serves — whose keys are agreed by the requirement's name, not by this file.
|
|
||||||
|
|
||||||
// bound is where a module says a value from one of its bindings belongs:
|
|
||||||
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
|
|
||||||
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
|
|
||||||
// absent requirement is never rendered as an empty string.
|
|
||||||
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
|
|
||||||
|
|
||||||
// knownFor is everything a module may name from one of its bindings.
|
|
||||||
//
|
|
||||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
|
||||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
|
||||||
// reason.
|
|
||||||
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
|
||||||
out := map[string]map[string]string{}
|
|
||||||
for _, want := range m.Wants() {
|
|
||||||
for i := range needs {
|
|
||||||
n := needs[i]
|
|
||||||
if n.Name != want || n.For != m.Module {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
|
||||||
values := map[string]string{
|
|
||||||
"at": n.At,
|
|
||||||
"from": n.From,
|
|
||||||
"as": as,
|
|
||||||
}
|
|
||||||
// What the provider derives for this consumer rather than for all of them
|
|
||||||
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
|
|
||||||
// requiring it are both in hand.
|
|
||||||
served, err := ServedTo(n.Serves, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
|
||||||
}
|
|
||||||
for key, value := range served {
|
|
||||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
|
||||||
// which is what a float would write and what a connection string would refuse.
|
|
||||||
values[key] = plainly(value)
|
|
||||||
}
|
|
||||||
out[want] = values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
|
||||||
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
|
||||||
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
|
||||||
// what the module is called is the mesh's statement, not the provider's.
|
|
||||||
func withOwnNames(values map[string]string, own map[string]any) {
|
|
||||||
for _, key := range []string{"name", "internal-name"} {
|
|
||||||
if v, ok := own[key].(string); ok {
|
|
||||||
values[key] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
many, _ := own["names"].(map[string]any)
|
|
||||||
for local, raw := range many {
|
|
||||||
names, _ := raw.(map[string]any)
|
|
||||||
for _, key := range []string{"name", "internal-name"} {
|
|
||||||
if v, ok := names[key].(string); ok {
|
|
||||||
values[key+"-"+local] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// plainly renders a served value as a program would expect to read it.
|
|
||||||
func plainly(value any) string {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
return v
|
|
||||||
case float64:
|
|
||||||
if v == float64(int64(v)) {
|
|
||||||
return fmt.Sprintf("%d", int64(v))
|
|
||||||
}
|
|
||||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
|
|
||||||
case bool:
|
|
||||||
return fmt.Sprintf("%t", v)
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
|
|
||||||
//
|
|
||||||
// A placeholder naming something the module does not require, or a key the provider does not
|
|
||||||
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
|
|
||||||
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
|
|
||||||
// somewhere that names neither the module nor the mesh.
|
|
||||||
//
|
|
||||||
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
|
|
||||||
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
|
|
||||||
// that already; this is the same rule where the file is made, for a manifest registered before it.
|
|
||||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var refused error
|
|
||||||
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
|
|
||||||
if refused != nil {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
m := bound.FindStringSubmatch(token)
|
|
||||||
provision, key, fallback := m[1], m[2], m[3]
|
|
||||||
values, has := known[provision]
|
|
||||||
if !has {
|
|
||||||
if absent[provision] {
|
|
||||||
if fallback != "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
refused = fmt.Errorf(
|
|
||||||
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
|
|
||||||
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
|
|
||||||
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
refused = fmt.Errorf(
|
|
||||||
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
|
|
||||||
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
value, said := values[key]
|
|
||||||
if !said {
|
|
||||||
refused = fmt.Errorf(
|
|
||||||
"%s asks its %s binding for %q, and what answers it says %s",
|
|
||||||
module, provision, key, orNothing(namesOfKeys(values)))
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
if refused != nil {
|
|
||||||
return refused
|
|
||||||
}
|
|
||||||
resource["content"] = filled
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfBindings(known map[string]map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for name := range known {
|
|
||||||
names = append(names, fmt.Sprintf("%q", name))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfKeys(values map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for key := range values {
|
|
||||||
names = append(names, fmt.Sprintf("%q", key))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func orNothing(names []string) string {
|
|
||||||
if len(names) == 0 {
|
|
||||||
return "nothing"
|
|
||||||
}
|
|
||||||
return join(names)
|
|
||||||
}
|
|
||||||
@@ -1,651 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Turning a manifest that names artifacts into one that names digests.
|
|
||||||
//
|
|
||||||
// **Two documents, deliberately.** The manifest in a repository says *this resource uses the
|
|
||||||
// archive called `config`*; the manifest the mesh holds says *this resource is sha256:…*. A digest
|
|
||||||
// is not knowable until something is built, so a repository carrying one would be a repository
|
|
||||||
// whose file is wrong the moment anybody edits anything — and the mesh would be pinning a value
|
|
||||||
// nobody could have checked.
|
|
||||||
//
|
|
||||||
// So the built manifest is **derived**, and the record of which commit it was derived from is what
|
|
||||||
// makes "is this current?" answerable without building (novox/hq ADR 0009).
|
|
||||||
|
|
||||||
// Built is one artifact after it exists: where it is and what it hashes to.
|
|
||||||
type Built struct {
|
|
||||||
// Name is what the manifest called it.
|
|
||||||
Name string
|
|
||||||
// Kind is "image" or "archive".
|
|
||||||
Kind string
|
|
||||||
// Reference is what a machine uses to get it — an image reference for an image, a URL for an
|
|
||||||
// archive. Both already carry the digest for an image; an archive carries it separately.
|
|
||||||
Reference string
|
|
||||||
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
|
||||||
Digest string
|
|
||||||
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
|
||||||
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
|
||||||
Launchers map[string]string
|
|
||||||
// Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the
|
|
||||||
// build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on
|
|
||||||
// the wire or kept: Resolve reads them into the files' content, which is what the mesh holds.
|
|
||||||
Texts map[string]string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve fills a manifest's resources in from what was built.
|
|
||||||
//
|
|
||||||
// Every resource naming an artifact is rewritten to name the thing itself, and the `artifact` key
|
|
||||||
// is removed — because it is a build-time word and the host has never heard of it. A resource
|
|
||||||
// naming an artifact nothing produced is refused: it would otherwise reach a machine with an
|
|
||||||
// empty image or an unpinned archive, which is the shape of failure that looks like success.
|
|
||||||
func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|
||||||
if m.Build == nil && len(built) == 0 {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
by := map[string]Built{}
|
|
||||||
for _, b := range built {
|
|
||||||
by[b.Name] = b
|
|
||||||
}
|
|
||||||
// Declared and not produced is a build that did not do what the manifest asked, and saying so
|
|
||||||
// here beats a machine reporting it later.
|
|
||||||
var missing []string
|
|
||||||
if m.Build != nil {
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if _, ok := by[a.Name]; !ok {
|
|
||||||
missing = append(missing, a.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
sort.Strings(missing)
|
|
||||||
return Manifest{}, fmt.Errorf(
|
|
||||||
"%s says it builds %s and the build did not produce %s — the build did not do what "+
|
|
||||||
"the manifest asked, which is a different fault from a resource asking for the "+
|
|
||||||
"wrong thing",
|
|
||||||
m.Module, strings.Join(missing, " and "), oneOrOther(len(missing)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
|
|
||||||
// the file's content, so what the mesh holds and composes is what the same text inline would be.
|
|
||||||
var handed *Handed
|
|
||||||
if m.Templated() {
|
|
||||||
name := TemplatesArtifact(m)
|
|
||||||
texts := by[name].Texts
|
|
||||||
if name == "" || texts == nil {
|
|
||||||
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
|
|
||||||
"archive it packs %s/ into (%q): the builder that built it does not read templates",
|
|
||||||
m.Module, m.TemplatesDir(), name)
|
|
||||||
}
|
|
||||||
dir := m.TemplatesDir()
|
|
||||||
filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) {
|
|
||||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
|
||||||
return t, ok
|
|
||||||
}, fmt.Sprintf("the archive %q", name))
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) {
|
|
||||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
|
||||||
return t, ok
|
|
||||||
}, fmt.Sprintf("the archive %q", name))
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
// And, for a moved module, where its templates are and which one each file and item is filled from
|
|
||||||
// (handed.go): read while its resources and items still name their templates.
|
|
||||||
handed = handedBy(m, by[name])
|
|
||||||
m.Resources = filled
|
|
||||||
m.Items = items
|
|
||||||
}
|
|
||||||
|
|
||||||
out := m
|
|
||||||
out.Handed = handed
|
|
||||||
out.Build = nil
|
|
||||||
// Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731).
|
|
||||||
out.Templates = ""
|
|
||||||
out.Resources = nil
|
|
||||||
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
|
|
||||||
// named by no resource of the module's own — the node's runtime loads it — so this is the only
|
|
||||||
// place the mesh would otherwise not have it. In artifact order, so two resolutions of one
|
|
||||||
// build compare equal.
|
|
||||||
out.Bundles = nil
|
|
||||||
if m.Build != nil {
|
|
||||||
run := runByAResource(m)
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a.Kind != ArtifactBundle {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
made := by[a.Name]
|
|
||||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
|
||||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
|
||||||
//
|
|
||||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
|
||||||
// A process the host runs is the module's service, not its tools: the controller declares
|
|
||||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
|
||||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
|
||||||
loads := append([]string(nil), a.Loads...)
|
|
||||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
|
||||||
loads = append([]string(nil), a.Entrypoints...)
|
|
||||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
|
||||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
|
||||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
|
||||||
if bin := BinaryOf(a); bin != "" {
|
|
||||||
loads = []string{bin}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
|
||||||
// it reached it by, and a manifest carrying that address names an installation —
|
|
||||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
|
||||||
// already keeps the store-relative form; the resolved manifest keeps the same, and
|
|
||||||
// composition routes it through the store a machine reaches (Routed).
|
|
||||||
out.Bundles = append(out.Bundles, Bundle{
|
|
||||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
|
||||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
|
||||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
|
||||||
Binary: BinaryOf(a),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
named, _ := r["artifact"].(string)
|
|
||||||
if named == "" {
|
|
||||||
out.Resources = append(out.Resources, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
artifact, ok := by[named]
|
|
||||||
if !ok {
|
|
||||||
return Manifest{}, fmt.Errorf(
|
|
||||||
"%s: %v uses the artifact %q, and this module builds no such thing",
|
|
||||||
m.Module, r["id"], named)
|
|
||||||
}
|
|
||||||
|
|
||||||
filled := map[string]any{}
|
|
||||||
for k, v := range r {
|
|
||||||
filled[k] = v
|
|
||||||
}
|
|
||||||
delete(filled, "artifact")
|
|
||||||
switch artifact.Kind {
|
|
||||||
case ArtifactPackage:
|
|
||||||
// A package is not a resource on any machine; it is consumed by other builds. A
|
|
||||||
// resource that names one is a manifest error, named here rather than shipped.
|
|
||||||
return Manifest{}, fmt.Errorf(
|
|
||||||
"%s: %v uses %q, which is a package — a build input, not a resource a machine runs",
|
|
||||||
m.Module, r["id"], named)
|
|
||||||
case ArtifactImage, ArtifactUpstream:
|
|
||||||
filled["image"] = artifact.Reference
|
|
||||||
// An image is not unpacked anywhere, so it has no directory to be named for its
|
|
||||||
// version and `${version}` has nothing to mean. Refused rather than left as literal
|
|
||||||
// text in a path, which is how it would reach a machine and be created as a directory
|
|
||||||
// called `${version}`.
|
|
||||||
for key, value := range filled {
|
|
||||||
if text, isText := value.(string); isText && strings.Contains(text, versionRef) {
|
|
||||||
return Manifest{}, fmt.Errorf(
|
|
||||||
"%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+
|
|
||||||
"it has no versioned place. %s is for an archive or a bundle",
|
|
||||||
m.Module, r["id"], versionRef, key, named, versionRef)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case ArtifactArchive, ArtifactBundle:
|
|
||||||
// The same on the wire: both are bytes fetched by digest and unpacked. They differ in
|
|
||||||
// how they were made — one packed as it stood, the other compiled first — and a
|
|
||||||
// machine has no reason to care which.
|
|
||||||
// Kept, not routed, for the reason the bundles above are (ADR 0155).
|
|
||||||
filled["source"] = Recorded(artifact.Reference)
|
|
||||||
filled["digest"] = artifact.Digest
|
|
||||||
// **And `${version}`, so a resource can name a place that is this build's alone**
|
|
||||||
// (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named
|
|
||||||
// for its version so it can read its own version from its path — and until this,
|
|
||||||
// nothing could compose that path: an archive named a fixed one in the manifest and
|
|
||||||
// nothing interpolated the build into it, so nothing could ask for
|
|
||||||
// `…/versions/<version>/` and every machine took a hand-placed fallback.
|
|
||||||
//
|
|
||||||
// The version is the artifact's own digest, short. Not the commit: two builds of one
|
|
||||||
// commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and
|
|
||||||
// a content-addressed version means an unchanged build resolves to the path it already
|
|
||||||
// had — so re-composing a declaration moves nothing, where a commit would move the
|
|
||||||
// path of an identical binary and recreate everything that reads it.
|
|
||||||
for key, value := range filled {
|
|
||||||
filled[key] = withVersion(value, versionOf(artifact.Digest))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
|
||||||
m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
|
||||||
ArtifactBundle)
|
|
||||||
}
|
|
||||||
out.Resources = append(out.Resources, filled)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map —
|
|
||||||
// a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is
|
|
||||||
// left as it is.
|
|
||||||
func withVersion(value any, version string) any {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
if strings.Contains(v, versionRef) {
|
|
||||||
return strings.ReplaceAll(v, versionRef, version)
|
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
out := make(map[string]any, len(v))
|
|
||||||
for k, x := range v {
|
|
||||||
out[k] = withVersion(x, version)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case map[string]string:
|
|
||||||
out := make(map[string]string, len(v))
|
|
||||||
for k, x := range v {
|
|
||||||
out[k] = strings.ReplaceAll(x, versionRef, version)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkBuild is the manifest's own account of what it builds.
|
|
||||||
func (b *Build) problems(module string) []string {
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, a := range b.Artifacts {
|
|
||||||
if a.Name == "" {
|
|
||||||
problems = append(problems, module+" builds an artifact with no name")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seen[a.Name] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s builds two artifacts called %q, and a resource naming it could mean either",
|
|
||||||
module, a.Name))
|
|
||||||
}
|
|
||||||
seen[a.Name] = true
|
|
||||||
switch a.Kind {
|
|
||||||
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
|
||||||
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
|
||||||
ArtifactBundle+", "+ArtifactPackage))
|
|
||||||
}
|
|
||||||
// **A bundle is built from the module itself, so it says a language instead.** Everything
|
|
||||||
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
|
|
||||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
|
||||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
|
||||||
// has not said.
|
|
||||||
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
|
|
||||||
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
|
|
||||||
}
|
|
||||||
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
|
||||||
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
|
||||||
module, a.Name, a.Kind))
|
|
||||||
}
|
|
||||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
|
||||||
// **Except for a language that compiles to a binary, where it names which one**
|
|
||||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
|
||||||
// directory compiled whole, and naming a source would be describing its own build. A
|
|
||||||
// repository written in a compiled language holds several commands — the host and its
|
|
||||||
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
|
||||||
// is then not a package at all. So the compiled case may say which package, and says
|
|
||||||
// the module root by saying nothing.
|
|
||||||
if a.From != "" && !compilesToABinary(a.Language) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
|
||||||
"from the module's own directory; what it says is the language",
|
|
||||||
module, a.Name, a.From))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Language) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
|
||||||
"for it", module, a.Name))
|
|
||||||
}
|
|
||||||
problems = append(problems, bundleEnvProblems(module, a)...)
|
|
||||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
|
||||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
|
||||||
// fail to import it on every machine rather than here.
|
|
||||||
for _, load := range a.Loads {
|
|
||||||
found := false
|
|
||||||
for _, e := range a.Entrypoints {
|
|
||||||
found = found || e == load
|
|
||||||
}
|
|
||||||
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
|
|
||||||
// (novox/hq ADR 0193).
|
|
||||||
if bin := BinaryOf(a); bin != "" {
|
|
||||||
found = load == bin
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
|
||||||
"what is loaded is compiled, so it is named there too", module, a.Name, load))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
|
||||||
// is pinned to one operating system at link time so a host refuses to touch a machine
|
|
||||||
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
|
||||||
// compiled for whatever the build machine happened to be, which reads as portable and
|
|
||||||
// is not.
|
|
||||||
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
|
||||||
"whatever the build machine happens to be. Declare one artifact per "+
|
|
||||||
"system: %s", module, a.Name, spokenSystems()))
|
|
||||||
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
|
||||||
"runs anywhere — a system that decides nothing reads as though it did",
|
|
||||||
module, a.Name, a.System, a.Language))
|
|
||||||
} else if compiled && !knownSystem(a.System) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is built for %q, and a system is %s",
|
|
||||||
module, a.Name, a.System, spokenSystems()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if a.From == "" && a.URL == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q says nothing about what it is built from", module, a.Name))
|
|
||||||
}
|
|
||||||
if a.Language != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
|
|
||||||
"everything else brings its own recipe", module, a.Name, a.Kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.Compiles != "" {
|
|
||||||
if a.Kind != ArtifactImage {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
|
|
||||||
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
|
|
||||||
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
|
|
||||||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// An upstream image is named, not read from the repository, so the path rule does not
|
|
||||||
// apply to it — and applying it anyway would refuse every reference with a registry host
|
|
||||||
// in it.
|
|
||||||
if a.Kind != ArtifactUpstream &&
|
|
||||||
(strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) {
|
|
||||||
// A build reads its own repository and nothing else. A path leaving it would make
|
|
||||||
// what gets built depend on whatever happens to be on the machine building it.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is built from %q, which is outside its own repository",
|
|
||||||
module, a.Name, a.From))
|
|
||||||
}
|
|
||||||
if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") {
|
|
||||||
// Without a tag or digest, what gets mirrored is whatever `latest` means today, and
|
|
||||||
// a module pinned to that is not pinned.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
|
|
||||||
}
|
|
||||||
problems = append(problems, upstreamFileProblems(module, a)...)
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's.
|
|
||||||
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
|
|
||||||
".test", ".invalid", ".example", ".intranet", ".private"}
|
|
||||||
|
|
||||||
// notAPublicHost says why a host is not a public vendor's name, or "".
|
|
||||||
func notAPublicHost(host string) string {
|
|
||||||
h := strings.ToLower(strings.TrimSuffix(host, "."))
|
|
||||||
if net.ParseIP(h) != nil {
|
|
||||||
return "an address rather than a name"
|
|
||||||
}
|
|
||||||
if !strings.Contains(h, ".") {
|
|
||||||
return "a name with no domain"
|
|
||||||
}
|
|
||||||
for _, s := range reservedSuffixes {
|
|
||||||
if strings.HasSuffix(h, s) {
|
|
||||||
return "a name under " + s + ", which is nobody's public domain"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters.
|
|
||||||
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
||||||
|
|
||||||
// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an
|
|
||||||
// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository
|
|
||||||
// besides; a file name or a directory within is a plain path that stays where it is put.
|
|
||||||
func upstreamFileProblems(module string, a Artifact) []string {
|
|
||||||
pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != ""
|
|
||||||
if !pinned {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
if a.Kind != ArtifactArchive {
|
|
||||||
say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+
|
|
||||||
"mirrored from one (novox/hq ADR 0316)", a.Kind)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if a.URL == "" {
|
|
||||||
say("names a sha256, a version, a file or a directory within, and no url to fetch it from")
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if a.From != "" {
|
|
||||||
say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL)
|
|
||||||
}
|
|
||||||
if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" {
|
|
||||||
say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL)
|
|
||||||
} else if why := notAPublicHost(u.Hostname()); why != "" {
|
|
||||||
say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why)
|
|
||||||
}
|
|
||||||
if !sha256Hex.MatchString(a.SHA256) {
|
|
||||||
say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+
|
|
||||||
"address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Version) == "" {
|
|
||||||
say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL)
|
|
||||||
}
|
|
||||||
if a.File != "" && a.Within != "" {
|
|
||||||
say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+
|
|
||||||
"of an archive", a.File, a.Within)
|
|
||||||
}
|
|
||||||
for field, p := range map[string]string{"file": a.File, "within": a.Within} {
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
clean := path.Clean(p)
|
|
||||||
if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") ||
|
|
||||||
(field == "file" && strings.Contains(p, "/")) {
|
|
||||||
say("names %s %q, which is not a plain relative %s", field, p, map[string]string{
|
|
||||||
"file": "file name", "within": "directory"}[field])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// oneOrOther keeps the message readable for one artifact and for several, because a message that
|
|
||||||
// says "neither" about one thing reads as a bug in the message.
|
|
||||||
func oneOrOther(n int) string {
|
|
||||||
if n == 1 {
|
|
||||||
return "it"
|
|
||||||
}
|
|
||||||
return "them"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
|
||||||
//
|
|
||||||
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
|
||||||
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
|
||||||
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
|
||||||
// matters is the one the host understands.
|
|
||||||
var systems = []string{"alpine", "android", "arch"}
|
|
||||||
|
|
||||||
// knownSystem is whether the mesh builds for it.
|
|
||||||
func knownSystem(system string) bool {
|
|
||||||
want := strings.ToLower(strings.TrimSpace(system))
|
|
||||||
for _, s := range systems {
|
|
||||||
if s == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
|
||||||
func spokenSystems() string {
|
|
||||||
return strings.Join(systems, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
|
||||||
// than code that runs wherever its interpreter does.
|
|
||||||
//
|
|
||||||
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
|
|
||||||
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
|
|
||||||
// would let two artifacts in one language disagree about whether they are portable.
|
|
||||||
func compilesToABinary(language string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
|
||||||
case "go":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// versionRef is how a resource names the version of the artifact it uses: ${version}.
|
|
||||||
//
|
|
||||||
// No artifact name in it, because the resource already says which artifact it is for — a second
|
|
||||||
// name would be a second thing to keep in step with the first.
|
|
||||||
const versionRef = "${version}"
|
|
||||||
|
|
||||||
// versionOf is an artifact's version as a path names it: its digest, short.
|
|
||||||
//
|
|
||||||
// **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds
|
|
||||||
// of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that
|
|
||||||
// reason. A commit-named path would move for an identical binary, and everything reading that path
|
|
||||||
// would be recreated for a change that is not one. A digest-named path moves exactly when the bytes
|
|
||||||
// do.
|
|
||||||
//
|
|
||||||
// Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to
|
|
||||||
// read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying
|
|
||||||
// a colon is a directory name people quote wrong.
|
|
||||||
func versionOf(digest string) string {
|
|
||||||
hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:")
|
|
||||||
if len(hex) > 12 {
|
|
||||||
return hex[:12]
|
|
||||||
}
|
|
||||||
return hex
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
|
||||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
|
||||||
var bundleEnvWords = map[string]bool{
|
|
||||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
|
||||||
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
|
||||||
// a value is a path or a constant written with the references a container's environment may use
|
|
||||||
// for a place or a port, and never a secret's content or another module's binding — a secret
|
|
||||||
// reaches a tool as a file whose path is named.
|
|
||||||
func bundleEnvProblems(module string, a Artifact) []string {
|
|
||||||
if len(a.Env) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, word := range sortedKeys(a.Env) {
|
|
||||||
value := a.Env[word]
|
|
||||||
if bundleEnvWords[word] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
|
||||||
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
|
||||||
module, a.Name, word))
|
|
||||||
}
|
|
||||||
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
|
||||||
if strings.Contains(rest, "${") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
|
||||||
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
|
||||||
"named by its path, never as its content (novox/hq ADR 0192)",
|
|
||||||
module, a.Name, word, value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyWords(in map[string]string) map[string]string {
|
|
||||||
if len(in) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make(map[string]string, len(in))
|
|
||||||
for k, v := range in {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
|
|
||||||
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
|
|
||||||
// language that does not compile to one. The builder writes the binary under this name, and the
|
|
||||||
// composer runs it by it, so both ask here.
|
|
||||||
func BinaryOf(a Artifact) string {
|
|
||||||
if !compilesToABinary(a.Language) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
if from := strings.Trim(a.From, "./"); from != "" {
|
|
||||||
return path.Base(from)
|
|
||||||
}
|
|
||||||
return a.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
|
||||||
// a step, an archive that unpacks it — by name.
|
|
||||||
func runByAResource(m Manifest) map[string]bool {
|
|
||||||
named := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if a, ok := r["artifact"].(string); ok && a != "" {
|
|
||||||
named[a] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return named
|
|
||||||
}
|
|
||||||
|
|
||||||
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
|
||||||
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
|
||||||
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
|
||||||
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
|
|
||||||
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
|
|
||||||
// naming the field that would deliver it.
|
|
||||||
func undeliveredBundles(m Manifest) []string {
|
|
||||||
if m.Build == nil || m.Module == RuntimeModule {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
named := runByAResource(m)
|
|
||||||
var problems []string
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
|
|
||||||
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
|
|
||||||
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
|
|
||||||
m.Module, a.Name))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A long-running container may say it is busy (novox/hq ADR 0335 §1).
|
|
||||||
//
|
|
||||||
// **Beside its health, on the container**: a command run inside it, by its shell, as an exec health check
|
|
||||||
// is, that exits 0 while the container holds work a replacement would lose and non-zero when it is idle;
|
|
||||||
// and how long one ask may take. Being asked is also its notice that it is about to be replaced: from the
|
|
||||||
// first ask it takes no new work. The node-engine asks before it replaces the container, leaves it running
|
|
||||||
// while it says busy, and replaces it on the first pass it says idle, or at 15 minutes — the engine's bound,
|
|
||||||
// never the module's.
|
|
||||||
//
|
|
||||||
// Sent only to a node-engine that reads it (link.BusyContract): an older, strict engine refuses a field it
|
|
||||||
// does not know, whole, and to it the container is replaced at once, as before.
|
|
||||||
|
|
||||||
// BusyField is the resource field.
|
|
||||||
const BusyField = "busy"
|
|
||||||
|
|
||||||
// The bounds of one ask (ADR 0335), the node-engine's own, held here near the author.
|
|
||||||
const (
|
|
||||||
BusyTimeoutDefault = 10 * time.Second
|
|
||||||
BusyTimeoutCeiling = time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// Busy is one container's busy, read.
|
|
||||||
type Busy struct {
|
|
||||||
Command string
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBusy reads a resource's `busy` field, its default applied. False when it carries none.
|
|
||||||
func ReadBusy(r map[string]any) (Busy, bool, []string) {
|
|
||||||
raw, present := r[BusyField]
|
|
||||||
if !present {
|
|
||||||
return Busy{}, false, nil
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
fields, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return Busy{}, true, []string{fmt.Sprintf("%s: busy is %T; it is an object with a command", id, raw)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
keys := make([]string, 0, len(fields))
|
|
||||||
for k := range fields {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
if k != "command" && k != "timeout" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its busy says %q; busy has a command and a timeout", id, k))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b := Busy{Timeout: BusyTimeoutDefault}
|
|
||||||
if v, has := fields["command"]; has {
|
|
||||||
s, ok := v.(string)
|
|
||||||
if !ok {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its busy's command is %T; it is text", id, v))
|
|
||||||
}
|
|
||||||
b.Command = strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
if v, has := fields["timeout"]; has {
|
|
||||||
s, _ := v.(string)
|
|
||||||
d, err := time.ParseDuration(strings.TrimSpace(s))
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its busy's timeout is %v; it is a duration such as \"10s\"", id, v))
|
|
||||||
case d <= 0 || d > BusyTimeoutCeiling:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: an ask of its busy takes more than nothing and at most %s, not %s",
|
|
||||||
id, BusyTimeoutCeiling, d))
|
|
||||||
default:
|
|
||||||
b.Timeout = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b, true, problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
|
|
||||||
// one line, a timeout within its bounds, and only on a container that stays up.
|
|
||||||
func busyProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
b, has, read := ReadBusy(r)
|
|
||||||
if !has {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
where := m.Module + ": " + fmt.Sprint(r["id"])
|
|
||||||
for _, p := range read {
|
|
||||||
problems = append(problems, m.Module+": "+p)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
|
|
||||||
"asked of a running container before it is replaced (ADR 0335)", where))
|
|
||||||
}
|
|
||||||
if b.Command == "" {
|
|
||||||
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
|
|
||||||
"while it is busy")
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(b.Command, "\n\r") {
|
|
||||||
problems = append(problems, where+"'s busy command is one line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
|
|
||||||
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
|
|
||||||
// container is replaced at once, as before.
|
|
||||||
func busyInto(resource map[string]any, with Rendering) {
|
|
||||||
if _, has := resource[BusyField]; !has {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !with.ReadsBusy {
|
|
||||||
delete(resource, BusyField)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b, _, _ := ReadBusy(resource)
|
|
||||||
resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
|
|
||||||
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
|
|
||||||
// two answers, so neither half alone says which. Module is empty only on a record made before this
|
|
||||||
// was asked, and such a record is honoured exactly as long as it is unambiguous.
|
|
||||||
type Chosen struct {
|
|
||||||
Node string
|
|
||||||
Module string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Chosen) String() string {
|
|
||||||
if c.Module == "" {
|
|
||||||
return c.Node
|
|
||||||
}
|
|
||||||
return c.Node + "/" + c.Module
|
|
||||||
}
|
|
||||||
|
|
||||||
// matches is whether this provider is the one chosen.
|
|
||||||
func (c Chosen) matches(p Provider) bool {
|
|
||||||
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// among is every offered provider the choice names — one, when the choice is whole.
|
|
||||||
func (c Chosen) among(where []Provider) []Provider {
|
|
||||||
var out []Provider
|
|
||||||
for _, p := range where {
|
|
||||||
if c.matches(p) {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// nameOf is how a refusal names a provider: the node and the module on it.
|
|
||||||
func nameOf(p Provider) string {
|
|
||||||
return Chosen{Node: p.Node, Module: p.Module}.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// providerNames is every provider named, sorted, for a refusal to list.
|
|
||||||
func providerNames(where []Provider) []string {
|
|
||||||
out := make([]string, 0, len(where))
|
|
||||||
for _, p := range where {
|
|
||||||
out = append(out, nameOf(p))
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// providersHere is which modules in this node's own set offer a provision, sorted.
|
|
||||||
func providersHere(catalogue map[string]Manifest, here func(string) bool, want string) []string {
|
|
||||||
var out []string
|
|
||||||
for name, m := range catalogue {
|
|
||||||
if !here(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, o := range m.Offers() {
|
|
||||||
if o == want {
|
|
||||||
out = append(out, name)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
|
|
||||||
//
|
|
||||||
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
|
|
||||||
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
|
|
||||||
// alone answers that; the values are settled later, with the node's settings.
|
|
||||||
func servedByOne(m Manifest, want string) map[string]any {
|
|
||||||
if _, ok := m.Serves[want]; ok {
|
|
||||||
return ServedOn(m, want, nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
|
|
||||||
// gives each consumer its own.
|
|
||||||
func sharedByOne(m Manifest, want string) string {
|
|
||||||
if own, shared := m.SharedCredentialOf(want); shared {
|
|
||||||
return own
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func oneOf(list []string, s string) bool {
|
|
||||||
for _, x := range list {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,635 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers.
|
|
||||||
//
|
|
||||||
// **A collector requires a collection; a contributor provides an item from a template.** A module taking additions
|
|
||||||
// from others names a collection in a file of its own, `${collected:<collection>}`, and declares it under
|
|
||||||
// `collections`, with the comment its tool writes a line in. Each contributor ships its item as a template of its
|
|
||||||
// own repository and declares it under `items`: `{"collection": …, "template": "files/<name>"}`. Its build packs the
|
|
||||||
// template with the module's others, and the mesh holds its text as it holds a file's (template.go).
|
|
||||||
//
|
|
||||||
// **The node's provisioner client fills both.** An item is filled on the node by the client of the node its
|
|
||||||
// contributor is placed on, from the same owners as a file, and recorded in mesh-provisioner_collect; a collector's
|
|
||||||
// file is filled by the same client from every item of its collection recorded for that node, so an item goes when
|
|
||||||
// its contributor leaves. So a module contributing an item is a moved module (handed.go), and the controller renders
|
|
||||||
// no item: the plan of a contributor names its items and their templates, and the plan of a collector names each
|
|
||||||
// collection it reads and its comment. Neither carries a contributor's text.
|
|
||||||
//
|
|
||||||
// **The three old namespaces read as collections.** `${shell:<shell>:<slot>}`, `${environment:<format>}` and
|
|
||||||
// `${contribution:<seat>:<kind>}` name, in a handed collector's file, the collection of the same name —
|
|
||||||
// `shell:zsh:normal` — with the comment the controller wrote before each module's piece. An item is contributed to
|
|
||||||
// one by that name, by a module the old form would have let contribute it. Each file is filled one way, never
|
|
||||||
// both (ADR 0349 clause 17): a handed collector is filled from items alone and an unhanded one from the old forms
|
|
||||||
// alone, and a piece of the other form on the same machine is said as not placed, never dropped quietly.
|
|
||||||
|
|
||||||
// Item is one item a module contributes to a collection: its template in the module's repository, or, in the
|
|
||||||
// manifest the mesh holds, the template's text, read back from the build's templates archive like a file's.
|
|
||||||
//
|
|
||||||
// **Attributes, a capability and a name** (design 57 L3, issue 777): an item may carry `attributes` (string keys and
|
|
||||||
// values; `order`, a whole number 0–99 defaulting to 50, orders the items of one placeholder) and `if-capability`,
|
|
||||||
// the capability a machine must report for the item to be placed on it. A module may give several items to one
|
|
||||||
// collection, each then with a `name` unique within the module and the collection. A collector takes only the items
|
|
||||||
// whose attributes match, `${collected:<collection>:<k>=<v>,…}`, and all of them without the selector.
|
|
||||||
type Item struct {
|
|
||||||
Collection string `json:"collection"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Template string `json:"template,omitempty"`
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
Attributes map[string]string `json:"attributes,omitempty"`
|
|
||||||
IfCapability string `json:"if-capability,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ItemKey is an item's key among its module's: the collection, and `/<name>` for a named item.
|
|
||||||
func ItemKey(collection, name string) string {
|
|
||||||
if name == "" {
|
|
||||||
return collection
|
|
||||||
}
|
|
||||||
return collection + "/" + name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (it Item) key() string { return ItemKey(it.Collection, it.Name) }
|
|
||||||
|
|
||||||
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
|
|
||||||
func (it Item) order() int {
|
|
||||||
if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
return OrderDefault
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderAttribute is the attribute that orders items.
|
|
||||||
const OrderAttribute = "order"
|
|
||||||
|
|
||||||
// matches is whether the item's attributes carry every pair of the selector.
|
|
||||||
func (it Item) matches(where map[string]string) bool {
|
|
||||||
for k, v := range where {
|
|
||||||
if got, has := it.Attributes[k]; !has || got != v {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
|
|
||||||
func itemsInOrder(items []Item) []Item {
|
|
||||||
out := append([]Item(nil), items...)
|
|
||||||
sort.SliceStable(out, func(a, b int) bool {
|
|
||||||
if oa, ob := out[a].order(), out[b].order(); oa != ob {
|
|
||||||
return oa < ob
|
|
||||||
}
|
|
||||||
return out[a].Name < out[b].Name
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item
|
|
||||||
// is introduced by `<comment> <contributor>`, and by nothing without one.
|
|
||||||
type Collection struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Comment string `json:"comment,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectedNamespace is a collection's placeholder namespace, `${collected:<collection>}`.
|
|
||||||
const CollectedNamespace = "collected"
|
|
||||||
|
|
||||||
// EnvironmentCollection is the one collection of the session environment (design 57 L3, issue 795): its items are
|
|
||||||
// typed by attributes, a variable (`kind=variable`, `name`) or a PATH entry (`kind=path`, `at=start|end`), and the
|
|
||||||
// template's text is the value or the entry. A collector names the rendering, `${collected:environment:posix}` or
|
|
||||||
// `${collected:environment:systemd}`, which is not a selector.
|
|
||||||
const EnvironmentCollection = "environment"
|
|
||||||
|
|
||||||
// oldCollectionNamespaces are the namespaces read as collections of their own name.
|
|
||||||
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
|
|
||||||
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
|
||||||
// collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads.
|
|
||||||
collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
|
|
||||||
// itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds
|
|
||||||
// a colon, a comma or an equals sign, which tell a selector from a collection's name.
|
|
||||||
itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
||||||
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
|
|
||||||
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
|
|
||||||
// before the first one.
|
|
||||||
func splitSelector(name string) (collection, selector string) {
|
|
||||||
eq := strings.Index(name, "=")
|
|
||||||
if eq < 0 {
|
|
||||||
return name, ""
|
|
||||||
}
|
|
||||||
colon := strings.LastIndex(name[:eq], ":")
|
|
||||||
if colon < 0 {
|
|
||||||
return name, ""
|
|
||||||
}
|
|
||||||
return name[:colon], name[colon+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectorOf is a selector's pairs, or why it is none.
|
|
||||||
func selectorOf(selector string) (map[string]string, error) {
|
|
||||||
out := map[string]string{}
|
|
||||||
if selector == "" {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
for _, pair := range strings.Split(selector, ",") {
|
|
||||||
k, v, ok := strings.Cut(pair, "=")
|
|
||||||
switch {
|
|
||||||
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
|
|
||||||
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
|
|
||||||
case out[k] != "":
|
|
||||||
return nil, fmt.Errorf("%q names %s twice", selector, k)
|
|
||||||
}
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
|
|
||||||
func collectionOfPlaceholder(namespace, key string) (string, bool) {
|
|
||||||
key, _ = splitSelector(key)
|
|
||||||
switch {
|
|
||||||
case namespace == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"):
|
|
||||||
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
|
|
||||||
return EnvironmentCollection, true
|
|
||||||
case namespace == CollectedNamespace:
|
|
||||||
return key, true
|
|
||||||
case namespace == "environment":
|
|
||||||
return EnvironmentCollection, true
|
|
||||||
case slices.Contains(oldCollectionNamespaces, namespace):
|
|
||||||
return namespace + ":" + key, true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
|
|
||||||
// ok is false for a name that is not an old namespace's.
|
|
||||||
//
|
|
||||||
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
|
|
||||||
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
|
|
||||||
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
|
|
||||||
namespace, key, _ := strings.Cut(name, ":")
|
|
||||||
if !slices.Contains(oldCollectionNamespaces, namespace) {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
switch namespace {
|
|
||||||
case "shell":
|
|
||||||
target, slot, two := strings.Cut(key, ":")
|
|
||||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
|
||||||
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
|
|
||||||
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
|
|
||||||
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
|
|
||||||
strings.Join(knownSlots, ", "))
|
|
||||||
}
|
|
||||||
// shellCode's line before each module's code.
|
|
||||||
return "#", true, nil
|
|
||||||
case "environment":
|
|
||||||
if key == "" {
|
|
||||||
// The collection itself, which an item is contributed to; a placeholder names a rendering.
|
|
||||||
return "#", true, nil
|
|
||||||
}
|
|
||||||
if key != EnvironmentPOSIX && key != EnvironmentSystemd {
|
|
||||||
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
|
|
||||||
name, EnvironmentPOSIX, EnvironmentSystemd)
|
|
||||||
}
|
|
||||||
return "#", true, nil
|
|
||||||
}
|
|
||||||
seat, kind, _ := strings.Cut(key, ":")
|
|
||||||
s, r, ok := receivable(seat, kind)
|
|
||||||
switch {
|
|
||||||
case !ok && s.Name == "":
|
|
||||||
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
|
|
||||||
case !ok:
|
|
||||||
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
|
|
||||||
case r.Shape != nil && (forItem || collectableAsText(r)):
|
|
||||||
return r.Comment, true, nil
|
|
||||||
case r.Shape != nil:
|
|
||||||
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
|
|
||||||
"(novox/hq ADR 0255)", name, s.Name)
|
|
||||||
case r.Dirs:
|
|
||||||
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
|
|
||||||
"the controller fills; it is not yet a collection (design 57 L1)", name)
|
|
||||||
case s.Name != canonicalSeat(seat):
|
|
||||||
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
|
|
||||||
}
|
|
||||||
return r.Comment, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// placerOfOld is the seat whose holder alone may collect an old namespace's collection (ADR 0203, 0204, 0208,
|
|
||||||
// 0211, 0212): the same seat that places it the old way.
|
|
||||||
func placerOfOld(name string) string {
|
|
||||||
namespace, key, _ := strings.Cut(name, ":")
|
|
||||||
switch namespace {
|
|
||||||
case "shell":
|
|
||||||
target, _, _ := strings.Cut(key, ":")
|
|
||||||
return placerOf(target)
|
|
||||||
case "environment":
|
|
||||||
return EnvironmentSeat
|
|
||||||
}
|
|
||||||
seat, _, _ := strings.Cut(key, ":")
|
|
||||||
return canonicalSeat(seat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
|
|
||||||
func collectionProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for i, c := range m.Collections {
|
|
||||||
switch {
|
|
||||||
case !collectionName.MatchString(c.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+
|
|
||||||
"words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name))
|
|
||||||
case declared[c.Name]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name))
|
|
||||||
}
|
|
||||||
if _, old, _ := oldCollection(c.Name, false); old {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+
|
|
||||||
"places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name,
|
|
||||||
c.Name))
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(c.Comment, "\n\r") {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break",
|
|
||||||
m.Module, c.Name))
|
|
||||||
}
|
|
||||||
declared[c.Name] = true
|
|
||||||
}
|
|
||||||
// A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here.
|
|
||||||
if !m.Templated() {
|
|
||||||
named := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if text, ok := r["content"].(string); ok {
|
|
||||||
for _, f := range ofCollected.FindAllStringSubmatch(text, -1) {
|
|
||||||
base, _ := splitSelector(f[1])
|
|
||||||
named[base] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range m.Collections {
|
|
||||||
if !named[c.Name] && collectionName.MatchString(c.Name) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+
|
|
||||||
"${collected:%s}", m.Module, c.Name, c.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contributed := map[string]bool{}
|
|
||||||
for i, it := range m.Items {
|
|
||||||
where := fmt.Sprintf("%s's item %d", m.Module, i+1)
|
|
||||||
if !collectionName.MatchString(it.Collection) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where,
|
|
||||||
it.Collection))
|
|
||||||
} else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v", where, wrong))
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case it.Name != "" && !itemName.MatchString(it.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+
|
|
||||||
"hyphens", where, it.Name))
|
|
||||||
case contributed[it.key()]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+
|
|
||||||
"a collection, or several each with a name of its own", m.Module, it.key()))
|
|
||||||
case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool {
|
|
||||||
return o.Collection == it.Collection && o.Name != ""
|
|
||||||
}):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+
|
|
||||||
"of several items has a name of its own", m.Module, it.Collection, where))
|
|
||||||
}
|
|
||||||
contributed[it.key()] = true
|
|
||||||
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
|
|
||||||
if it.Collection == EnvironmentCollection {
|
|
||||||
problems = append(problems, environmentItemProblems(where, it)...)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case it.Template == "" && it.Content == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+
|
|
||||||
"own, \"template\": \"%s/<name>\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir()))
|
|
||||||
case it.Template != "" && it.Content != "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where))
|
|
||||||
case it.Template != "":
|
|
||||||
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s", where, wrong))
|
|
||||||
}
|
|
||||||
// **Filled by the provisioner's client alone**: the controller renders no item.
|
|
||||||
if !m.Moved {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+
|
|
||||||
"node its contributor is placed on, so a module contributing one is marked moved "+
|
|
||||||
"(\"moved\": true; novox/hq ADR 0349 §13, §17)", where))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if it.Content != "" {
|
|
||||||
problems = append(problems, itemTextProblems(m.Module, it)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind
|
|
||||||
// received as data are the fields its shape places by (and `order`), each with a value the shape takes.
|
|
||||||
func itemAttributeProblems(module, where string, it Item) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, k := range sortedKeys(it.Attributes) {
|
|
||||||
v := it.Attributes[k]
|
|
||||||
switch {
|
|
||||||
case !attributeKey.MatchString(k):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+
|
|
||||||
"and hyphens", where, k))
|
|
||||||
continue
|
|
||||||
case !attributeFits(it.Collection, k, v):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+
|
|
||||||
"dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" {
|
|
||||||
seat, kind, _ := strings.Cut(key, ":")
|
|
||||||
if _, r, ok := receivable(seat, kind); ok && r.Shape != nil {
|
|
||||||
for _, k := range sortedKeys(it.Attributes) {
|
|
||||||
var place *PlaceField
|
|
||||||
for i := range r.Shape.Places {
|
|
||||||
if r.Shape.Places[i].Field == k {
|
|
||||||
place = &r.Shape.Places[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case k == OrderAttribute:
|
|
||||||
case place == nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where,
|
|
||||||
it.Collection, placeFields(r), k))
|
|
||||||
case !oneOf(place.Values, it.Attributes[k]):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k,
|
|
||||||
strings.Join(place.Values, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case it.IfCapability == "":
|
|
||||||
case !capabilityName.MatchString(it.IfCapability):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability))
|
|
||||||
case !oneOf(KnownCapabilities, it.IfCapability):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+
|
|
||||||
"detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", ")))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except
|
|
||||||
// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's
|
|
||||||
// provisioner client's, and the inventory's.
|
|
||||||
func attributeFits(collection, k, v string) bool {
|
|
||||||
if collection == EnvironmentCollection && k == "name" {
|
|
||||||
return variableName.MatchString(v)
|
|
||||||
}
|
|
||||||
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable
|
|
||||||
// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`.
|
|
||||||
func environmentItemProblems(where string, it Item) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, k := range sortedKeys(it.Attributes) {
|
|
||||||
if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them",
|
|
||||||
where, it.Collection, k))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch kind := it.Attributes["kind"]; kind {
|
|
||||||
case "variable":
|
|
||||||
if it.Attributes["name"] == "" || it.Attributes["at"] != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: a variable names itself with name=<NAME> and has no at", where))
|
|
||||||
} else if it.Attributes["name"] == "PATH" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where))
|
|
||||||
}
|
|
||||||
case "path":
|
|
||||||
if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
|
|
||||||
PathAtStart, PathAtEnd))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where,
|
|
||||||
it.Collection, kind))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
|
||||||
func itemTemplateProblem(m Manifest, name string) string {
|
|
||||||
dir := m.TemplatesDir()
|
|
||||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
|
||||||
strings.Contains(name, "\\") {
|
|
||||||
return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+
|
|
||||||
"plain relative path, and the build packs only %s/", name, dir, dir+"/<name>", dir)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a
|
|
||||||
// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every
|
|
||||||
// placeholder no pass fills, as in a file's content.
|
|
||||||
func itemTextProblems(module string, it Item) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) {
|
|
||||||
namespace, key := f[1], f[2]
|
|
||||||
switch _, collection := collectionOfPlaceholder(namespace, key); {
|
|
||||||
case namespace == "secret" || namespace == "bound":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+
|
|
||||||
"no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0]))
|
|
||||||
case collection:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector",
|
|
||||||
module, it.Collection, f[0]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(),
|
|
||||||
"type": "file", "content": it.Content})...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isOld is whether a collection's name is an old namespace's.
|
|
||||||
func isOld(name string) bool {
|
|
||||||
_, old, _ := oldCollection(name, true)
|
|
||||||
return old
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
|
|
||||||
// and naming a collection the module declares it collects.
|
|
||||||
func collectedPlaceholderProblems(m Manifest, r map[string]any) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, field := range sortedKeys(r) {
|
|
||||||
v, ok := r[field].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
|
|
||||||
base, selector := splitSelector(f[1])
|
|
||||||
if _, err := selectorOf(selector); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case field != "content":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
|
|
||||||
"only in a file's content", m.Module, r["id"], f[0], field))
|
|
||||||
case isOld(base):
|
|
||||||
if _, _, wrong := oldCollection(base, false); wrong != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
|
|
||||||
}
|
|
||||||
case !slices.ContainsFunc(m.Collections, func(c Collection) bool { return c.Name == base }):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
|
|
||||||
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
|
|
||||||
func collectionComment(m Manifest, name string) (string, error) {
|
|
||||||
if comment, old, wrong := oldCollection(name, false); old {
|
|
||||||
return comment, wrong
|
|
||||||
}
|
|
||||||
for _, c := range m.Collections {
|
|
||||||
if c.Name == name {
|
|
||||||
return c.Comment, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemsLeftOut says each item of a collection a file is filled with the old way that the controller could not render
|
|
||||||
// there (heldItemText): the collector is not handed to the provisioner on this machine (ADR 0349 clause 17). Said,
|
|
||||||
// never dropped quietly.
|
|
||||||
func itemsLeftOut(modules []Manifest, holder Manifest, collection string, facts map[string]string) []string {
|
|
||||||
return itemsLeftOutFor(modules, holder, collection, nil, facts, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
|
|
||||||
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
|
|
||||||
// wanted here.
|
|
||||||
func itemsLeftOutFor(modules []Manifest, holder Manifest, collection string, where map[string]string,
|
|
||||||
facts map[string]string, caps map[string]bool) []string {
|
|
||||||
var out []string
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
for _, it := range itemsInOrder(m.Items) {
|
|
||||||
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
|
|
||||||
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
|
|
||||||
m.Module, it.key(), holder.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
|
|
||||||
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
|
|
||||||
func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanCollection) []string {
|
|
||||||
var out []string
|
|
||||||
for _, c := range collections {
|
|
||||||
namespace, key, _ := strings.Cut(c.Name, ":")
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
old := false
|
|
||||||
switch namespace {
|
|
||||||
case "shell":
|
|
||||||
target, slot, _ := strings.Cut(key, ":")
|
|
||||||
old = slices.ContainsFunc(m.Shell, func(s ShellCode) bool { return s.For == target && s.Slot == slot })
|
|
||||||
case "environment":
|
|
||||||
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
|
|
||||||
case "contribution":
|
|
||||||
seat, kind, _ := strings.Cut(key, ":")
|
|
||||||
old = slices.ContainsFunc(m.allContributions(), func(s SeatContribution) bool {
|
|
||||||
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if old {
|
|
||||||
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
|
|
||||||
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
|
|
||||||
holder.Module, m.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// heldItemText is a moved contributor's items of an old namespace's collection as the controller renders them into a
|
|
||||||
// collector not handed on this machine (design 57 L2, issue 681): the text the mesh holds, read back from the
|
|
||||||
// contributor's templates archive, with the machine's facts filled, each ending its line. As a moved module's file
|
|
||||||
// keeps its text for a machine that cannot take it handed (handed.go), an item keeps its place in a collector filled
|
|
||||||
// the old way, so the file stays what its old form made it. Only `shell:` and `contribution:` collections, which the
|
|
||||||
// controller renders as text; false for a module with no such item, or an item whose text the mesh does not hold or
|
|
||||||
// which names anything but a machine fact this machine has, which is then said as not placed.
|
|
||||||
func heldItemText(m Manifest, collection string, facts map[string]string) (string, bool) {
|
|
||||||
items := heldItems(m, collection, nil, facts, nil)
|
|
||||||
var b strings.Builder
|
|
||||||
for _, h := range items {
|
|
||||||
b.WriteString(h.text)
|
|
||||||
}
|
|
||||||
return b.String(), len(items) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
|
|
||||||
func renderedOldWay(collection string) bool {
|
|
||||||
namespace, _, _ := strings.Cut(collection, ":")
|
|
||||||
return namespace == "shell" || namespace == "contribution" || namespace == EnvironmentCollection
|
|
||||||
}
|
|
||||||
|
|
||||||
// heldItem is one item of a module rendered the old way.
|
|
||||||
type heldItem struct {
|
|
||||||
item Item
|
|
||||||
text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
|
|
||||||
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
|
|
||||||
func heldItems(m Manifest, collection string, where map[string]string, facts map[string]string,
|
|
||||||
caps map[string]bool) []heldItem {
|
|
||||||
if !renderedOldWay(collection) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []heldItem
|
|
||||||
for _, it := range itemsInOrder(m.Items) {
|
|
||||||
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if text, ok := heldText(it, facts); ok {
|
|
||||||
out = append(out, heldItem{item: it, text: text})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
|
|
||||||
// does not hold or which names anything but a machine fact this machine has.
|
|
||||||
func heldText(it Item, facts map[string]string) (string, bool) {
|
|
||||||
if it.Content == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
text := it.Content
|
|
||||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
|
|
||||||
value, has := facts[f[2]]
|
|
||||||
if f[1] != "machine" || !has {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
text = strings.ReplaceAll(text, f[0], value)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(text, "\n") {
|
|
||||||
text += "\n"
|
|
||||||
}
|
|
||||||
return text, true
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660).
|
|
||||||
//
|
|
||||||
// **One concept, two spellings for a while.** "Config" replaces "settings": the manifest says `config` for
|
|
||||||
// `settings`, `takes-config` for `takes-settings` and `${config:<key>}` for `${setting:<key>}`. The old names stay
|
|
||||||
// accepted as aliases until every module has moved (ADR 0348 §1), and a module uses one form or the other, never
|
|
||||||
// both. The config form is read into the very fields the settings form fills — Settings, a mergeable file's
|
|
||||||
// takes-settings and `${setting:<key>}` — so composition has one path and `${config:<key>}` fills exactly as
|
|
||||||
// `${setting:<key>}` does: a module rewritten from settings to config composes byte-identically. It is written
|
|
||||||
// back in the form it was read in, so what the mesh holds is what its author wrote.
|
|
||||||
//
|
|
||||||
// **A moved module is held to the whole declaration.** Under `config` every value has a type (number, string or
|
|
||||||
// boolean; an object or a list only when trusted), a description including its unit, its validation (enum,
|
|
||||||
// minimum and maximum, pattern), whether it is trusted, optionally the predefined key it falls back to, and either
|
|
||||||
// a default or `required`; it lands in a file or is read at runtime (`runtime: true`); every mergeable file names
|
|
||||||
// the keys it takes; a `reload-on` or `restart-on` names a file the module declares. A module not yet moved keeps
|
|
||||||
// today's form and today's rules (SettingProblems, TakenSettingsProblems) until the catalogue's list of such
|
|
||||||
// modules is empty (ADR 0348 §11).
|
|
||||||
//
|
|
||||||
// **A required value nothing sets** is the operator's own: it composes as an operator's setting nobody gave does
|
|
||||||
// today, refused by name at composition (UnsetSettingError). Who reports it once mesh-config answers values is the
|
|
||||||
// provisioner's (ADR 0349), not this file's.
|
|
||||||
|
|
||||||
// ConfigField is the manifest key a module declares its config values under.
|
|
||||||
const ConfigField = "config"
|
|
||||||
|
|
||||||
// TakesConfigField is the key a mergeable file names the config values it takes by.
|
|
||||||
const TakesConfigField = "takes-config"
|
|
||||||
|
|
||||||
// SettingsField is the old name of ConfigField, read as its alias.
|
|
||||||
const SettingsField = "settings"
|
|
||||||
|
|
||||||
// configRef is how a file asks for a config value: ${config:<key>}, the same keys as settingRef.
|
|
||||||
var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`)
|
|
||||||
|
|
||||||
// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never
|
|
||||||
// followed by a colon, and a quote inside a string is escaped, so these match only keys.
|
|
||||||
var (
|
|
||||||
takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`)
|
|
||||||
takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// The types a config value may have (ADR 0348 §4).
|
|
||||||
const (
|
|
||||||
ConfigNumber = "number"
|
|
||||||
ConfigString = "string"
|
|
||||||
ConfigBoolean = "boolean"
|
|
||||||
ConfigObject = "object"
|
|
||||||
ConfigList = "list"
|
|
||||||
)
|
|
||||||
|
|
||||||
// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11).
|
|
||||||
var accountFacts = map[string]bool{"puid": true, "pgid": true}
|
|
||||||
|
|
||||||
// configForms is which of the two forms a manifest was written in, by the words of each it uses.
|
|
||||||
type configForms struct {
|
|
||||||
config, settings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or
|
|
||||||
// `${config:<key>}` (ADR 0348 §1, §11).
|
|
||||||
func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 }
|
|
||||||
|
|
||||||
// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and
|
|
||||||
// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read
|
|
||||||
// (UnmarshalJSON).
|
|
||||||
func toSettingsForm(raw []byte) ([]byte, configForms) {
|
|
||||||
var f configForms
|
|
||||||
if configRef.Match(raw) {
|
|
||||||
f.config = append(f.config, "${config:…}")
|
|
||||||
}
|
|
||||||
if takesConfigKey.Match(raw) {
|
|
||||||
f.config = append(f.config, TakesConfigField)
|
|
||||||
}
|
|
||||||
if settingRef.Match(raw) {
|
|
||||||
f.settings = append(f.settings, "${setting:…}")
|
|
||||||
}
|
|
||||||
if takesSettingsKey.Match(raw) {
|
|
||||||
f.settings = append(f.settings, TakesSettingsField)
|
|
||||||
}
|
|
||||||
if len(f.config) == 0 {
|
|
||||||
return raw, f
|
|
||||||
}
|
|
||||||
raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
|
||||||
return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}")
|
|
||||||
})
|
|
||||||
raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
|
||||||
return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":")
|
|
||||||
})
|
|
||||||
return raw, f
|
|
||||||
}
|
|
||||||
|
|
||||||
// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what
|
|
||||||
// toSettingsForm and UnmarshalJSON read, written back.
|
|
||||||
func toConfigForm(raw []byte) ([]byte, error) {
|
|
||||||
var keys map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if settings, has := keys[SettingsField]; has {
|
|
||||||
keys[ConfigField] = settings
|
|
||||||
delete(keys, SettingsField)
|
|
||||||
var err error
|
|
||||||
if raw, err = json.Marshal(keys); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte {
|
|
||||||
return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}")
|
|
||||||
})
|
|
||||||
raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte {
|
|
||||||
return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":")
|
|
||||||
})
|
|
||||||
return raw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// inConfigWords is a refusal said of a module in the config form in its words: `${config:<key>}` and
|
|
||||||
// takes-config, where the rules shared with the settings form say `${setting:<key>}` and takes-settings.
|
|
||||||
func inConfigWords(problem string) string {
|
|
||||||
problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string {
|
|
||||||
return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}"
|
|
||||||
})
|
|
||||||
return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1).
|
|
||||||
func MixedFormProblems(m Manifest) []string {
|
|
||||||
if len(m.forms.config) == 0 || len(m.forms.settings) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+
|
|
||||||
"or the other, never both; write %s, %s and ${config:<key>} throughout (novox/hq ADR 0348 §1)",
|
|
||||||
m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "),
|
|
||||||
ConfigField, TakesConfigField)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
|
|
||||||
// unsaid.
|
|
||||||
type configDeclaration struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Enum []any `json:"enum,omitempty"`
|
|
||||||
Minimum *float64 `json:"minimum,omitempty"`
|
|
||||||
Maximum *float64 `json:"maximum,omitempty"`
|
|
||||||
Pattern string `json:"pattern,omitempty"`
|
|
||||||
Trusted bool `json:"trusted,omitempty"`
|
|
||||||
Predefined string `json:"predefined,omitempty"`
|
|
||||||
Default any `json:"default,omitempty"`
|
|
||||||
Required bool `json:"required,omitempty"`
|
|
||||||
Runtime bool `json:"runtime,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
|
|
||||||
// form's kind and why, and without a default it does not have.
|
|
||||||
func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
|
||||||
type plain SettingDeclaration
|
|
||||||
if d.Kind != "" || d.Why != "" {
|
|
||||||
return json.Marshal(plain(d))
|
|
||||||
}
|
|
||||||
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
|
|
||||||
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
|
|
||||||
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
|
||||||
}
|
|
||||||
|
|
||||||
// configShaped is whether a declaration says anything of the config form's.
|
|
||||||
func configShaped(d SettingDeclaration) bool {
|
|
||||||
return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil ||
|
|
||||||
d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime
|
|
||||||
}
|
|
||||||
|
|
||||||
// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type,
|
|
||||||
// a description and its validation is declared in the config form (ADR 0348 §1, §4).
|
|
||||||
func TypedSettingProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, key := range sortedSettingKeys(m.Settings) {
|
|
||||||
if configShaped(m.Settings[key]) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+
|
|
||||||
"under %s: a value declared with those is declared under config, with ${config:<key>} and "+
|
|
||||||
"takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with
|
|
||||||
// what to do (ADR 0348 How it is checked, design 57 package K1).
|
|
||||||
func ConfigProblems(m Manifest) []string {
|
|
||||||
if !m.DeclaresConfig() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
placed := configPlaced(m)
|
|
||||||
fallsBack := fileFallsBack(m)
|
|
||||||
renamed := renamedTaken(m)
|
|
||||||
for _, key := range sortedSettingKeys(m.Settings) {
|
|
||||||
d := m.Settings[key]
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
if !settingRef.MatchString("${setting:" + key + "}") {
|
|
||||||
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if meshWords[key] || accountFacts[key] {
|
|
||||||
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if d.Kind != "" || d.Why != "" {
|
|
||||||
say("kind and why are the settings form's; under config a value says its type and its description " +
|
|
||||||
"(novox/hq ADR 0348 §1, §4)")
|
|
||||||
}
|
|
||||||
switch d.Type {
|
|
||||||
case ConfigNumber, ConfigString, ConfigBoolean:
|
|
||||||
case ConfigObject, ConfigList:
|
|
||||||
if !d.Trusted {
|
|
||||||
say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+
|
|
||||||
"trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type)
|
|
||||||
}
|
|
||||||
case "":
|
|
||||||
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
|
|
||||||
default:
|
|
||||||
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(d.Description) == "" {
|
|
||||||
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case d.Required && d.Default != nil:
|
|
||||||
say("both a default and required: a required value is the operator's own and has no default; give " +
|
|
||||||
"one or the other (novox/hq ADR 0348 §4)")
|
|
||||||
case !d.Required && d.Default == nil:
|
|
||||||
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
|
|
||||||
"the operator's own (novox/hq ADR 0348 §4)")
|
|
||||||
}
|
|
||||||
if as := renamed[key]; as != "" && d.Required {
|
|
||||||
say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+
|
|
||||||
"it is not: it is never required; give it the file's default, the empty value of its type "+
|
|
||||||
"(novox/hq issue 751)", as)
|
|
||||||
}
|
|
||||||
for _, wrong := range validationProblems(d) {
|
|
||||||
say("%s", wrong)
|
|
||||||
}
|
|
||||||
if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] {
|
|
||||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+
|
|
||||||
"unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+
|
|
||||||
"(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word)
|
|
||||||
}
|
|
||||||
if d.Predefined != "" {
|
|
||||||
p, listed := m.predefined[d.Predefined]
|
|
||||||
switch {
|
|
||||||
case m.predefined == nil:
|
|
||||||
say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+
|
|
||||||
"the catalogue's repository) is not present where it is judged: a value falls back only to a "+
|
|
||||||
"key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)",
|
|
||||||
d.Predefined, PredefinedFile)
|
|
||||||
case !listed:
|
|
||||||
say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+
|
|
||||||
"\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined)
|
|
||||||
default:
|
|
||||||
if p.Type != d.Type {
|
|
||||||
say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+
|
|
||||||
"back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type)
|
|
||||||
}
|
|
||||||
if p.Trusted && !d.Trusted {
|
|
||||||
say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+
|
|
||||||
"back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither
|
|
||||||
// judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again
|
|
||||||
// where it is registered, and this is judged then.
|
|
||||||
if !placed[key] && !d.Runtime && !m.Templated() {
|
|
||||||
say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+
|
|
||||||
"\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
used := settingKeysUsedBy(m)
|
|
||||||
for _, key := range sortedKeys(used) {
|
|
||||||
if _, declared := m.Settings[key]; !declared {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+
|
|
||||||
"declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)",
|
|
||||||
m.Module, key, ConfigField, key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
problems = append(problems, configTakesProblems(m)...)
|
|
||||||
problems = append(problems, reflectsProblems(m)...)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and
|
|
||||||
// every value a mergeable file takes.
|
|
||||||
func configPlaced(m Manifest) map[string]bool {
|
|
||||||
placed := settingKeysUsedBy(m)
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
for _, k := range takenKeyList(r) {
|
|
||||||
placed[k] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return placed
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply
|
|
||||||
// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named
|
|
||||||
// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its
|
|
||||||
// value in the content, else the empty value of its type, never laid over the file (the operator's decision on
|
|
||||||
// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's
|
|
||||||
// value belongs (ADR 0112).
|
|
||||||
func fileFallsBack(m Manifest) map[string]bool {
|
|
||||||
asked := settingKeysUsedBy(m)
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if how, _ := r["merge"].(string); how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, k := range takenKeyList(r) {
|
|
||||||
if !asked[k] {
|
|
||||||
out[k] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
|
|
||||||
func renamedTaken(m Manifest) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if how, _ := r["merge"].(string); how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, e := range takenEntries(r[TakesSettingsField]) {
|
|
||||||
if e.renamed {
|
|
||||||
out[e.key] = e.as
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
|
||||||
func validationProblems(d SettingDeclaration) []string {
|
|
||||||
var problems []string
|
|
||||||
isNumber := d.Type == ConfigNumber
|
|
||||||
if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != ConfigString {
|
|
||||||
problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type))
|
|
||||||
}
|
|
||||||
if (d.Minimum != nil || d.Maximum != nil) && !isNumber {
|
|
||||||
problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type))
|
|
||||||
}
|
|
||||||
if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum {
|
|
||||||
problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum))
|
|
||||||
}
|
|
||||||
var pattern *regexp.Regexp
|
|
||||||
if d.Pattern != "" {
|
|
||||||
if d.Type != ConfigString {
|
|
||||||
problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type))
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, v := range d.Enum {
|
|
||||||
if !ofType(v, d.Type) {
|
|
||||||
problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.Default == nil {
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if !ofType(d.Default, d.Type) {
|
|
||||||
if d.Type != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) {
|
|
||||||
problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum))
|
|
||||||
}
|
|
||||||
if n, ok := d.Default.(float64); ok {
|
|
||||||
if d.Minimum != nil && n < *d.Minimum {
|
|
||||||
problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum))
|
|
||||||
}
|
|
||||||
if d.Maximum != nil && n > *d.Maximum {
|
|
||||||
problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) {
|
|
||||||
problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// ofType is whether a value read from JSON is of a config type.
|
|
||||||
func ofType(v any, kind string) bool {
|
|
||||||
switch v.(type) {
|
|
||||||
case float64:
|
|
||||||
return kind == ConfigNumber
|
|
||||||
case string:
|
|
||||||
return kind == ConfigString
|
|
||||||
case bool:
|
|
||||||
return kind == ConfigBoolean
|
|
||||||
case map[string]any:
|
|
||||||
return kind == ConfigObject
|
|
||||||
case []any:
|
|
||||||
return kind == ConfigList
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in
|
|
||||||
// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file
|
|
||||||
// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that
|
|
||||||
// does not).
|
|
||||||
func configTakesProblems(m Manifest) []string {
|
|
||||||
var problems, silent []string
|
|
||||||
naming := false
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if how, _ := r["merge"].(string); how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, said := r[TakesSettingsField]; !said {
|
|
||||||
silent = append(silent, fmt.Sprint(r["id"]))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
naming = true
|
|
||||||
for _, k := range takenKeyList(r) {
|
|
||||||
_, declared := m.Settings[k]
|
|
||||||
switch {
|
|
||||||
case accountFacts[k]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+
|
|
||||||
"mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k,
|
|
||||||
TakesConfigField, k))
|
|
||||||
case meshWords[k]:
|
|
||||||
// Refused for both forms by TakenSettingsProblems, in its words.
|
|
||||||
case !declared:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+
|
|
||||||
"%s: a value a file takes is declared like every other, with its type, description and a "+
|
|
||||||
"default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)",
|
|
||||||
m.Module, r["id"], k, TakesConfigField, ConfigField))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !naming {
|
|
||||||
for _, id := range silent {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+
|
|
||||||
"names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)",
|
|
||||||
m.Module, id, TakesConfigField))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name
|
|
||||||
// without declaring them; meshMadePrefixes are those named after what they are for.
|
|
||||||
var (
|
|
||||||
meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()}
|
|
||||||
meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")}
|
|
||||||
)
|
|
||||||
|
|
||||||
// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not
|
|
||||||
// one of its own resources, not one the mesh composes for it, and not another module's, which is named
|
|
||||||
// `<module>.<id>`.
|
|
||||||
func reflectsProblems(m Manifest) []string {
|
|
||||||
own := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
own[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
meshMade := func(id string) bool {
|
|
||||||
if slices.Contains(meshMadeIDs, id) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, p := range meshMadePrefixes {
|
|
||||||
if strings.HasPrefix(id, p) && len(id) > len(p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
for _, field := range []string{"reload-on", "restart-on"} {
|
|
||||||
for _, id := range stringsOf(r[field]) {
|
|
||||||
if strings.Contains(id, ".") || own[id] || meshMade(id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+
|
|
||||||
"file: name a file of its own, or another module's as <module>.<id> (novox/hq ADR 0348 §6)",
|
|
||||||
m.Module, r["id"], id, field, m.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// What the move of config reads of a module's declaration (novox/hq ADR 0348 §9, design 57 K5, issue 664): where a
|
|
||||||
// layer's key goes, how two layers' values become one, and whether a value is one its declaration takes.
|
|
||||||
|
|
||||||
// TakenAs is each name a mergeable file takes a config value under that is not the value's config key, with that
|
|
||||||
// key (issue 751): a layer set before the module moved may still say the file's name, and the move sets it under
|
|
||||||
// the config key.
|
|
||||||
func TakenAs(m Manifest) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
for key, as := range renamedTaken(m) {
|
|
||||||
out[as] = key
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AskedKeys is every value a placeholder of the module asks for: a file, a unit, a directory's owner, a
|
|
||||||
// contribution or a served fact. Such a value is taken whole from the last layer that sets it, where a mergeable
|
|
||||||
// file lays one object over another key by key.
|
|
||||||
func AskedKeys(m Manifest) map[string]bool { return settingKeysUsedBy(m) }
|
|
||||||
|
|
||||||
// LaidOver is a value laid over another as a later layer is laid over an earlier: two objects key by key, anything
|
|
||||||
// else replaced whole.
|
|
||||||
func LaidOver(under, over any) any { return mergeValue(under, over) }
|
|
||||||
|
|
||||||
// ValueProblems is what is wrong with a value for a declaration: its type, and its enum, bounds or pattern.
|
|
||||||
func ValueProblems(d SettingDeclaration, v any) []string {
|
|
||||||
if v == nil {
|
|
||||||
return []string{"no value"}
|
|
||||||
}
|
|
||||||
d.Default = v
|
|
||||||
d.Required = false
|
|
||||||
var problems []string
|
|
||||||
for _, p := range validationProblems(d) {
|
|
||||||
problems = append(problems, strings.ReplaceAll(p, "its default", "the value"))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a provider derives for one consumer, said once in the provider's definition and delivered
|
|
||||||
// to both ends (novox/hq ADR 0202, issue 124).
|
|
||||||
//
|
|
||||||
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
|
|
||||||
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
|
|
||||||
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
|
|
||||||
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
|
|
||||||
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
|
|
||||||
//
|
|
||||||
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
|
|
||||||
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
|
|
||||||
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
|
|
||||||
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
|
|
||||||
// served value is a string.
|
|
||||||
|
|
||||||
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
|
||||||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
|
||||||
|
|
||||||
// consumerFacts are what a served value may name about the consumer it is being derived for.
|
|
||||||
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
|
|
||||||
// so it is the one thing the mesh can hand to a provider without either end guessing.
|
|
||||||
var consumerFacts = []string{"as"}
|
|
||||||
|
|
||||||
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
|
|
||||||
// identifier with its separator written `-` instead of `_` — the whole of the difference between
|
|
||||||
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
|
|
||||||
var consumerAlphabets = []string{"dns"}
|
|
||||||
|
|
||||||
// ServedTo fills a provider's served values for one consumer.
|
|
||||||
//
|
|
||||||
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
|
|
||||||
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
|
|
||||||
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
|
|
||||||
// nothing.
|
|
||||||
//
|
|
||||||
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
|
|
||||||
// stated outright, and is left alone.
|
|
||||||
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
|
||||||
if len(serves) == 0 {
|
|
||||||
return serves, nil
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
for _, key := range sortedAnyKeys(serves) {
|
|
||||||
text, ok := serves[key].(string)
|
|
||||||
if !ok || !strings.Contains(text, "${consumer:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled, err := consumerInto(text, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("the value served as %q: %w", key, err)
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
// Copied only once something actually changes: the caller's map is the manifest's,
|
|
||||||
// and a provider that derives nothing must not have it rewritten underneath it.
|
|
||||||
out = make(map[string]any, len(serves))
|
|
||||||
for k, v := range serves {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[key] = filled
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
return serves, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumerInto replaces every `${consumer:…}` in one value.
|
|
||||||
//
|
|
||||||
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
|
||||||
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
|
||||||
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
|
||||||
// is refused by (boundInto).
|
|
||||||
func consumerInto(value, as string) (string, error) {
|
|
||||||
var failed error
|
|
||||||
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
|
||||||
parts := consumerFact.FindStringSubmatch(match)
|
|
||||||
fact, alphabet := parts[1], parts[2]
|
|
||||||
if fact != "as" {
|
|
||||||
if failed == nil {
|
|
||||||
failed = fmt.Errorf(
|
|
||||||
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
switch alphabet {
|
|
||||||
case "":
|
|
||||||
return as
|
|
||||||
case "dns":
|
|
||||||
return asDNSLabel(as)
|
|
||||||
default:
|
|
||||||
if failed == nil {
|
|
||||||
failed = fmt.Errorf(
|
|
||||||
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if failed != nil {
|
|
||||||
return "", failed
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// asDNSLabel writes a minted identity as a DNS label.
|
|
||||||
//
|
|
||||||
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
|
||||||
// already inside the bound of the provision serving them: a provider that serves one as a DNS label
|
|
||||||
// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
|
|
||||||
// saying is held to twenty (IdentityBoundOf). So
|
|
||||||
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
|
||||||
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
|
||||||
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
|
||||||
func asDNSLabel(as string) string {
|
|
||||||
return strings.ReplaceAll(as, "_", "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
|
|
||||||
// have, when the definition is parsed rather than when a consumer is resolved.
|
|
||||||
//
|
|
||||||
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
|
|
||||||
// first time somebody required it is a definition that is wrong now.
|
|
||||||
func CheckServes(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, provision := range sortedServes(m.Serves) {
|
|
||||||
for _, key := range sortedAnyKeys(m.Serves[provision]) {
|
|
||||||
text, ok := m.Serves[provision][key].(string)
|
|
||||||
if !ok {
|
|
||||||
// A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one
|
|
||||||
// nested in a list or an object would be served as the placeholder, unsaid.
|
|
||||||
problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// A probe identity, because what is checked is the shape of the statement and not
|
|
||||||
// what any consumer is called.
|
|
||||||
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
|
||||||
}
|
|
||||||
// And the provider's own names it may serve (novox/hq ADR 0346).
|
|
||||||
problems = append(problems, checkServedOwnNames(m, provision, key, text)...)
|
|
||||||
// A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own
|
|
||||||
// bound has to keep the identity inside one (ADR 0225).
|
|
||||||
if strings.Contains(text, "${consumer:as:dns}") {
|
|
||||||
if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s serves %s's consumers their identity as a DNS label in %q, and bounds "+
|
|
||||||
"that identity at %s: a label keeps %d — state an `identity` of at most %d",
|
|
||||||
m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4).
|
|
||||||
const dnsLabelLimit = 63
|
|
||||||
|
|
||||||
func boundWords(b IdentityBound) string {
|
|
||||||
if !b.Bounded() {
|
|
||||||
return "nothing"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d", b.Max)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedServes(serves map[string]map[string]any) []string {
|
|
||||||
out := make([]string, 0, len(serves))
|
|
||||||
for k := range serves {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedAnyKeys(values map[string]any) []string {
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for k := range values {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// derivedFor is what the provider on this machine derives for one consumer of one provision
|
|
||||||
// (novox/hq ADR 0202).
|
|
||||||
//
|
|
||||||
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
|
|
||||||
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
|
|
||||||
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
|
|
||||||
// already in the provider's own definition, so repeating it here would be a second copy to go
|
|
||||||
// stale.
|
|
||||||
//
|
|
||||||
// The first module in the resolved order that says it serves the provision answers, which is the
|
|
||||||
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
|
|
||||||
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
|
|
||||||
// then there is nothing derived to tell.
|
|
||||||
func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) {
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
serves, said := m.Serves[provision]
|
|
||||||
if !said {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var names map[string]any
|
|
||||||
for key, value := range serves {
|
|
||||||
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
|
|
||||||
if names == nil {
|
|
||||||
names = map[string]any{}
|
|
||||||
}
|
|
||||||
names[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if names == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
// **A consumer that keeps several holders of this provision is refused** — this is issue
|
|
||||||
// 124's own failure one case to the side, and it would be just as quiet.
|
|
||||||
//
|
|
||||||
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
|
|
||||||
// login, so it would make one resource per holder. The consumer's side has no such
|
|
||||||
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
|
|
||||||
// derived from the un-suffixed identity. So the provider would create the holder's
|
|
||||||
// resource and the consumer would be configured against a name nothing made — it would
|
|
||||||
// authenticate successfully and be refused on every object, which reads like a credential
|
|
||||||
// fault and is not one.
|
|
||||||
//
|
|
||||||
// Lifting this means giving the consumer's side a local dimension. That is a decision,
|
|
||||||
// not an omission, and until it is taken the mesh says so rather than guessing.
|
|
||||||
if local != "" {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
|
|
||||||
"consumer from the login the mesh minted. Each holder has its own login, and a "+
|
|
||||||
"consumer is told one value per requirement — so the two ends would name "+
|
|
||||||
"different things and nothing would compare them (novox/hq ADR 0202)",
|
|
||||||
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
|
|
||||||
}
|
|
||||||
settled, err := Settle(names, WithDefaults(m, settings[m.Module]))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
|
|
||||||
}
|
|
||||||
derived, err := ServedTo(settled, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
|
|
||||||
}
|
|
||||||
return derived, nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
|
|
||||||
// instead of asking for it (novox/hq ADR 0202, issue 124).
|
|
||||||
//
|
|
||||||
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
|
|
||||||
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
|
|
||||||
// nothing compared it to what the provider would actually create: the module would have
|
|
||||||
// authenticated successfully and been refused on every object, which reads like a credential fault
|
|
||||||
// and is not one. It looked authoritative for months.
|
|
||||||
//
|
|
||||||
// The test is exact and costs one string search: a definition whose file already contains the
|
|
||||||
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
|
|
||||||
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
|
|
||||||
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
|
|
||||||
// because after substitution every consumer's file contains it legitimately.
|
|
||||||
//
|
|
||||||
// Only values that actually name the consumer are judged. A provider that serves a constant under
|
|
||||||
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
|
|
||||||
// rather than wrong.
|
|
||||||
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok || content == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, provision := range sortedKnown(known) {
|
|
||||||
values := known[provision]
|
|
||||||
identity := values["as"]
|
|
||||||
if identity == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, key := range sortedStringKeys(values) {
|
|
||||||
if key == "as" {
|
|
||||||
// The login is not derived from itself, and a consumer that must present it in a
|
|
||||||
// connection string legitimately has it from `${bound:…}` — which is what it will
|
|
||||||
// be after substitution, so this would judge the substitution, not the module.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value := values[key]
|
|
||||||
if value == "" || !namesTheConsumer(value, identity) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, value) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
|
|
||||||
"keeping its own copy of somebody else's naming rule is one that can disagree "+
|
|
||||||
"with it, silently. Say ${bound:%s:%s} and be told",
|
|
||||||
module, value, resource["id"], provision, provision, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
|
|
||||||
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
|
|
||||||
// from it, whatever else it may be.
|
|
||||||
func namesTheConsumer(value, identity string) bool {
|
|
||||||
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedKnown(known map[string]map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(known))
|
|
||||||
for k := range known {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedStringKeys(values map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for k := range values {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A provider's consumers' credentials, in a file of the provider's own (novox/hq ADR 0373, issue 679).
|
|
||||||
//
|
|
||||||
// A provider in the tool runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its template names
|
|
||||||
// `${consumers:<provision>}` and its machine's provisioner client fills it: one entry per consumer bound to the
|
|
||||||
// provider for that provision, each carrying the credential the vault keeps for the pair. Only the client fills it,
|
|
||||||
// so the placeholder is a provider's alone, for a provision it provides, in a file only the provider reads (mode 0600
|
|
||||||
// or 0400, owned at runtime, so a change on disk is written back); and a module not handed to the client on a machine
|
|
||||||
// is refused there, never written the placeholder's text, nor an empty list the provider would read as no consumers.
|
|
||||||
|
|
||||||
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
|
|
||||||
const ConsumersNamespace = "consumers"
|
|
||||||
|
|
||||||
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
|
|
||||||
// refused rather than passed over.
|
|
||||||
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
|
|
||||||
|
|
||||||
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
|
|
||||||
// for a provision the module provides, in a file only the provider reads and the client writes back.
|
|
||||||
func consumersPlaceholderProblems(m Manifest, r map[string]any) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, field := range sortedKeys(r) {
|
|
||||||
v, ok := r[field].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
|
|
||||||
switch {
|
|
||||||
case field != "content" || fmt.Sprint(r["type"]) != "file":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
|
|
||||||
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
|
|
||||||
continue
|
|
||||||
case !slices.ContainsFunc(m.Provides, func(o Offer) bool { return o.Name == f[1] }):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
|
|
||||||
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
|
||||||
m.Module, f[1]))
|
|
||||||
}
|
|
||||||
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
|
|
||||||
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
|
||||||
r["mode"]))
|
|
||||||
}
|
|
||||||
if owner := ownershipOf(r); owner != RuntimeOwned {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
|
|
||||||
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
|
|
||||||
r["id"], f[0], owner))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
|
|
||||||
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
|
|
||||||
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
|
|
||||||
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
|
|
||||||
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine
|
|
||||||
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
|
|
||||||
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
|
|
||||||
func consumersHandedOnly(m Manifest, r map[string]any, with Rendering) (judged, leftOut bool) {
|
|
||||||
content, _ := r["content"].(string)
|
|
||||||
switch {
|
|
||||||
case ofConsumers.FindString(content) == "":
|
|
||||||
return false, false
|
|
||||||
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
|
|
||||||
return true, false
|
|
||||||
}
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
|
|
||||||
func consumersLeftOutLine(m Manifest, r map[string]any) string {
|
|
||||||
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
|
|
||||||
"(novox/hq ADR 0373)", m.Module, r["id"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
|
|
||||||
func consumersNotHanded(m Manifest, r map[string]any, with Rendering) (judged bool, err error) {
|
|
||||||
judged, left := consumersHandedOnly(m, r, with)
|
|
||||||
if left {
|
|
||||||
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
|
|
||||||
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
|
|
||||||
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
|
|
||||||
}
|
|
||||||
return judged, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
|
|
||||||
// resource as if its text were inline.
|
|
||||||
func consumersOfTemplate(m Manifest, id, text string) []string {
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["id"]) != id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
withText := map[string]any{}
|
|
||||||
for k, v := range r {
|
|
||||||
withText[k] = v
|
|
||||||
}
|
|
||||||
withText["content"] = text
|
|
||||||
return consumersPlaceholderProblems(m, withText)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The core's own credentials (novox/hq ADR 0349 §16, issue 755). The bus, the node-engine, the controller and
|
|
||||||
// mesh-provisioner need their files before any provisioner client runs, so their files stay placement and their
|
|
||||||
// credentials travel in the declaration as values the vault sealed to the machine they run on, which the controller
|
|
||||||
// carries and cannot open; the node-engine opens them there (mesh-host's VaultBoxContract).
|
|
||||||
|
|
||||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
|
||||||
// its client. The node-engine and the controller hold no own secret of a module's. **Fixed here, in the controller's
|
|
||||||
// code, as the clause says** — and the vault keeps the same list (mesh-vault's CoreModules), answering the controller
|
|
||||||
// for these alone.
|
|
||||||
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
|
||||||
|
|
||||||
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
|
|
||||||
func CarriesVaultSealed(module string) bool { return slices.Contains(CoreCredentialModules, module) }
|
|
||||||
|
|
||||||
// VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed).
|
|
||||||
const VaultSealedField = "vault-sealed"
|
|
||||||
|
|
||||||
// ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept
|
|
||||||
// yet: the send asks the vault for it. Never sent.
|
|
||||||
var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`)
|
|
||||||
|
|
||||||
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
|
|
||||||
func (with Rendering) vaultSealedOf(module, name string) (json.RawMessage, bool) {
|
|
||||||
box, ok := with.VaultSealed[module][name]
|
|
||||||
return box, ok && len(box) > 0
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
// A five-field cron validator, enough to refuse a malformed schedule near its author
|
|
||||||
// (novox/hq ADR 0053).
|
|
||||||
//
|
|
||||||
// **Validation only, and written rather than pulled in.** The control plane's part in a scheduled
|
|
||||||
// step is small and exact: carry the field to the host unchanged, and refuse a `schedule` that is
|
|
||||||
// not a well-formed cron here rather than on the machine — the same near-versus-far discipline the
|
|
||||||
// manifest keeps for an action a module may not declare and a run-once that is not a boolean. What
|
|
||||||
// *when to run it again* means is the host's to evaluate; the control plane only checks the string
|
|
||||||
// is one it could. A cron library would be a dependency carried for evaluation nobody does here.
|
|
||||||
//
|
|
||||||
// The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists
|
|
||||||
// (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host
|
|
||||||
// evaluates, and refusing a name here is refusing it near whoever wrote it.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// validateCron reports why a string is not a five-field cron expression, or nil if it is one.
|
|
||||||
func validateCron(expr string) error {
|
|
||||||
fields := strings.Fields(expr)
|
|
||||||
if len(fields) != 5 {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"a schedule is a five-field cron expression (minute hour day-of-month month "+
|
|
||||||
"day-of-week), and this has %d field(s)", len(fields))
|
|
||||||
}
|
|
||||||
bounds := []struct {
|
|
||||||
name string
|
|
||||||
min, max int
|
|
||||||
}{
|
|
||||||
{"minute", 0, 59},
|
|
||||||
{"hour", 0, 23},
|
|
||||||
{"day-of-month", 1, 31},
|
|
||||||
{"month", 1, 12},
|
|
||||||
{"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps
|
|
||||||
}
|
|
||||||
for i, b := range bounds {
|
|
||||||
if err := validateCronField(fields[i], b.min, b.max); err != nil {
|
|
||||||
return fmt.Errorf("%s field: %w", b.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateCronField checks one field's elements are within range and well-formed.
|
|
||||||
func validateCronField(spec string, min, max int) error {
|
|
||||||
if spec == "" {
|
|
||||||
return fmt.Errorf("is empty")
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(spec, ",") {
|
|
||||||
if part == "" {
|
|
||||||
return fmt.Errorf("%q has an empty element between commas", spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
rangePart := part
|
|
||||||
if slash := strings.IndexByte(part, '/'); slash >= 0 {
|
|
||||||
rangePart = part[:slash]
|
|
||||||
n, err := strconv.Atoi(part[slash+1:])
|
|
||||||
if err != nil || n < 1 {
|
|
||||||
return fmt.Errorf("step in %q is not a positive number", part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case rangePart == "*":
|
|
||||||
// Any value; nothing to bound.
|
|
||||||
case strings.IndexByte(rangePart, '-') >= 0:
|
|
||||||
dash := strings.IndexByte(rangePart, '-')
|
|
||||||
lo, errLo := strconv.Atoi(rangePart[:dash])
|
|
||||||
hi, errHi := strconv.Atoi(rangePart[dash+1:])
|
|
||||||
if errLo != nil || errHi != nil {
|
|
||||||
return fmt.Errorf("range %q is not two numbers", rangePart)
|
|
||||||
}
|
|
||||||
if lo < min || hi > max || lo > hi {
|
|
||||||
return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
v, err := strconv.Atoi(rangePart)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%q is not a number", rangePart)
|
|
||||||
}
|
|
||||||
if v < min || v > max {
|
|
||||||
return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,791 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module declares the data it holds, and the mesh protects and watches it from that declaration
|
|
||||||
// (novox/hq ADR 0233).
|
|
||||||
//
|
|
||||||
// **One section, `data`, for every kind of data a module keeps:** its own, by directory — a store's
|
|
||||||
// files, a mail spool, an application's uploads — or by an operator's path it was given; what it keeps
|
|
||||||
// for its consumers, by the provision they reach it through; and what of its own lives with a
|
|
||||||
// provider, by the provision it requires. Each entry has a class — how precious it is — and, for its
|
|
||||||
// own data, how it is protected; everything the mesh does is derived from those, never written per
|
|
||||||
// module:
|
|
||||||
//
|
|
||||||
// - a binding to a consumer's data does not move (ADR 0232) where the provision's class keeps data;
|
|
||||||
// - the backup holder's lines are composed from it — a module no longer writes them by hand;
|
|
||||||
// - what an unassignment leaves behind of an irreplaceable or valuable item is retired, listed by
|
|
||||||
// `cleanup list` and deleted only by `cleanup delete` (ADR 0230);
|
|
||||||
// - the self-check measures every item and says when one shrinks, disappears, stops being written,
|
|
||||||
// goes without its backup, sits on a degraded array, or is replaced by an empty copy of itself —
|
|
||||||
// urgent for what is irreplaceable, a warning for what is valuable.
|
|
||||||
|
|
||||||
// The classes: how precious the data is. A fixed vocabulary, ranked by the operator (2026-10-06): a
|
|
||||||
// class is what the protections are derived from, so a new one is a decision, not a manifest's choice.
|
|
||||||
const (
|
|
||||||
// ClassIrreplaceable is what must never be lost: the operator names it (the media library, the
|
|
||||||
// photo sites' storage). Protected by a backup or by a declared redundancy — one is required —
|
|
||||||
// retired rather than removed, and every alert about it is urgent.
|
|
||||||
ClassIrreplaceable = "irreplaceable"
|
|
||||||
// ClassValuable is anybody's work that would be painful to lose: in the nightly backup by default,
|
|
||||||
// retired rather than removed, and every alert about it a warning.
|
|
||||||
ClassValuable = "valuable"
|
|
||||||
// ClassRebuildable can be made again from something kept elsewhere — a clone, an index, a
|
|
||||||
// download, a night's dump — at a cost in time, not in data. In the nightly backup by default;
|
|
||||||
// forgotten when its module goes, and never alerted on.
|
|
||||||
ClassRebuildable = "rebuildable"
|
|
||||||
// ClassCache may be emptied at any moment with nothing lost but speed: never backed up, never
|
|
||||||
// measured, never alerted on.
|
|
||||||
ClassCache = "cache"
|
|
||||||
// ClassNone is a provision that keeps nothing of its consumers' (consumers only): a resolver, a
|
|
||||||
// certificate authority, an artifact store.
|
|
||||||
ClassNone = "none"
|
|
||||||
)
|
|
||||||
|
|
||||||
// classRank orders the classes by how precious they are, so the stricter of two is chosen.
|
|
||||||
var classRank = map[string]int{ClassNone: 0, ClassCache: 1, ClassRebuildable: 2, ClassValuable: 3, ClassIrreplaceable: 4}
|
|
||||||
|
|
||||||
// StricterClass is the more precious of two classes.
|
|
||||||
func StricterClass(a, b string) string {
|
|
||||||
if classRank[b] > classRank[a] {
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retires is whether a class's data is retired, not forgotten, when its machine no longer declares it.
|
|
||||||
func Retires(class string) bool { return class == ClassIrreplaceable || class == ClassValuable }
|
|
||||||
|
|
||||||
// Watched is whether a class's data raises conditions: irreplaceable and valuable.
|
|
||||||
func Watched(class string) bool { return Retires(class) }
|
|
||||||
|
|
||||||
// DefaultBackupWithin is how old the last good backup of an item may be before the self-check says so
|
|
||||||
// (novox/hq ADR 0214: a machine with data and no good backup in 48 hours).
|
|
||||||
const DefaultBackupWithin = 48 * time.Hour
|
|
||||||
|
|
||||||
// Data is a manifest's `data` section.
|
|
||||||
type Data struct {
|
|
||||||
// Own is the data this module keeps itself.
|
|
||||||
Own []DataItem `json:"own,omitempty"`
|
|
||||||
// Consumers is what it keeps for its consumers, per provision it grants.
|
|
||||||
Consumers map[string]ConsumerData `json:"consumers,omitempty"`
|
|
||||||
// KeptBy is what of its own lives with the provider of a provision it requires — its rows, its
|
|
||||||
// objects — and how precious that is. The provider's protections follow the stricter of its own
|
|
||||||
// class for its consumers and this.
|
|
||||||
KeptBy map[string]KeptData `json:"kept-by,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataItem is one piece of a module's own data.
|
|
||||||
type DataItem struct {
|
|
||||||
// ID names it within the module: what `data`, `cleanup` and a condition call it.
|
|
||||||
ID string `json:"id"`
|
|
||||||
// Path is one of the module's directories, `${dir:<id>}`, or an operator's path it was given,
|
|
||||||
// `${access:<id>}`, or a path beneath either. Never a machine path (novox/hq ADR 0112).
|
|
||||||
Path string `json:"path"`
|
|
||||||
Class string `json:"class"`
|
|
||||||
// Backup is how it is copied: "copy" (the holder reads it as it stands), "none", or a dump — a
|
|
||||||
// command that writes a consistent copy into another item, which is copied. Unsaid, anything but a
|
|
||||||
// cache is copied, unless it says it is protected by redundancy instead.
|
|
||||||
Backup *DataBackup `json:"backup,omitempty"`
|
|
||||||
// Redundancy says it is protected by the redundancy of the storage it lives on rather than by a
|
|
||||||
// copy, and why that is enough: the media library on an array there is no room to copy. The
|
|
||||||
// backup holder then watches that array, and a degraded one is said.
|
|
||||||
Redundancy string `json:"redundancy,omitempty"`
|
|
||||||
// Within is how old its last good backup may be; unsaid, DefaultBackupWithin.
|
|
||||||
Within string `json:"within,omitempty"`
|
|
||||||
// Measure is how the backup holder measures it: `walk` (the default — every file, at most daily and
|
|
||||||
// bounded, for small items), `dataset` (a ZFS dataset's own counters, hourly, nothing walked) or
|
|
||||||
// `shallow` (its top-level entries only, no size). A large item never says walk.
|
|
||||||
Measure string `json:"measure,omitempty"`
|
|
||||||
// Active is how long it may go unwritten before that is a fault — for data something is
|
|
||||||
// expected to write all the time. Unsaid, a quiet item is not a fault.
|
|
||||||
Active string `json:"active,omitempty"`
|
|
||||||
// Why is a line for the reviewer: why this class.
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsumerData is what a provider keeps for the consumers of one provision.
|
|
||||||
type ConsumerData struct {
|
|
||||||
Class string `json:"class"`
|
|
||||||
// In is where it lives: one of the module's own items (whose protection covers it), or a
|
|
||||||
// provision this module requires (whose provider keeps it, as its consumer). Unsaid only for none
|
|
||||||
// and cache.
|
|
||||||
In string `json:"in,omitempty"`
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeptData is how precious what a module keeps with a provider is.
|
|
||||||
type KeptData struct {
|
|
||||||
Class string `json:"class"`
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataBackup is how an item is copied.
|
|
||||||
type DataBackup struct {
|
|
||||||
Copy bool
|
|
||||||
None bool
|
|
||||||
// Dump is the command writing a consistent copy into the item Into.
|
|
||||||
Dump string
|
|
||||||
Into string
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads "copy", "none" or {"dump": "...", "into": "<item>"}.
|
|
||||||
func (b *DataBackup) UnmarshalJSON(raw []byte) error {
|
|
||||||
var word string
|
|
||||||
if err := json.Unmarshal(raw, &word); err == nil {
|
|
||||||
switch word {
|
|
||||||
case "copy":
|
|
||||||
*b = DataBackup{Copy: true}
|
|
||||||
case "none":
|
|
||||||
*b = DataBackup{None: true}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("a data item's backup is \"copy\", \"none\" or {dump, into}, not %q", word)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var full struct {
|
|
||||||
Dump string `json:"dump"`
|
|
||||||
Into string `json:"into"`
|
|
||||||
}
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&full); err != nil {
|
|
||||||
return fmt.Errorf("a data item's backup is \"copy\", \"none\" or {dump, into}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
*b = DataBackup{Dump: full.Dump, Into: full.Into}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON writes it back in the form it was written.
|
|
||||||
func (b DataBackup) MarshalJSON() ([]byte, error) {
|
|
||||||
switch {
|
|
||||||
case b.Copy:
|
|
||||||
return json.Marshal("copy")
|
|
||||||
case b.None:
|
|
||||||
return json.Marshal("none")
|
|
||||||
}
|
|
||||||
return json.Marshal(struct {
|
|
||||||
Dump string `json:"dump"`
|
|
||||||
Into string `json:"into"`
|
|
||||||
}{b.Dump, b.Into})
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDump is whether the item is copied by a dump.
|
|
||||||
func (b *DataBackup) IsDump() bool { return b != nil && b.Dump != "" }
|
|
||||||
|
|
||||||
// BackedUp is whether the holder keeps restore points of this item: anything but a cache by default —
|
|
||||||
// the nightly backup is the standard plan — unless it says "none", or says it is protected by
|
|
||||||
// redundancy and says nothing of a backup.
|
|
||||||
func (it DataItem) BackedUp() bool {
|
|
||||||
switch {
|
|
||||||
case it.Backup == nil:
|
|
||||||
return it.Class != ClassCache && it.Redundancy == ""
|
|
||||||
case it.Backup.None:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protection is how the item is protected, in one word: "backup", "redundancy", both joined by "+",
|
|
||||||
// or "none".
|
|
||||||
func (it DataItem) Protection() string {
|
|
||||||
var by []string
|
|
||||||
if it.BackedUp() {
|
|
||||||
by = append(by, "backup")
|
|
||||||
}
|
|
||||||
if it.Redundancy != "" {
|
|
||||||
by = append(by, "redundancy")
|
|
||||||
}
|
|
||||||
if len(by) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
return strings.Join(by, "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The ways an item is measured.
|
|
||||||
const (
|
|
||||||
MeasureWalk = "walk"
|
|
||||||
MeasureDataset = "dataset"
|
|
||||||
MeasureShallow = "shallow"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MeasuredBy is how the item is measured, with the default applied.
|
|
||||||
func (it DataItem) MeasuredBy() string {
|
|
||||||
if it.Measure == "" {
|
|
||||||
return MeasureWalk
|
|
||||||
}
|
|
||||||
return it.Measure
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnedByModule is whether the item is in one of the module's own directories — the mesh's to retire —
|
|
||||||
// rather than an operator's path it was given, which the mesh never retires and never deletes.
|
|
||||||
func (it DataItem) OwnedByModule() bool { return strings.HasPrefix(it.Path, "${dir:") }
|
|
||||||
|
|
||||||
// BackupWithin is the item's bound on its last good backup.
|
|
||||||
func (it DataItem) BackupWithin() time.Duration {
|
|
||||||
if d, err := ParseDataDuration(it.Within); err == nil && d > 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return DefaultBackupWithin
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActiveWithin is how long the item may go unwritten; zero when quiet is not a fault.
|
|
||||||
func (it DataItem) ActiveWithin() time.Duration {
|
|
||||||
d, _ := ParseDataDuration(it.Active)
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseDataDuration reads "48h", "90m" or "7d"; empty is zero.
|
|
||||||
func ParseDataDuration(s string) (time.Duration, error) {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if days, ok := strings.CutSuffix(s, "d"); ok {
|
|
||||||
n, err := strconv.Atoi(days)
|
|
||||||
if err != nil || n <= 0 {
|
|
||||||
return 0, fmt.Errorf("%q is not a number of days", s)
|
|
||||||
}
|
|
||||||
return time.Duration(n) * 24 * time.Hour, nil
|
|
||||||
}
|
|
||||||
d, err := time.ParseDuration(s)
|
|
||||||
if err != nil || d <= 0 {
|
|
||||||
return 0, fmt.Errorf("%q is not a duration (48h, 90m, 7d)", s)
|
|
||||||
}
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataItems is the module's own data, in the order declared.
|
|
||||||
func (m Manifest) DataItems() []DataItem {
|
|
||||||
if m.Data == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m.Data.Own
|
|
||||||
}
|
|
||||||
|
|
||||||
// DataItem is one own item by id.
|
|
||||||
func (m Manifest) DataItem(id string) (DataItem, bool) {
|
|
||||||
for _, it := range m.DataItems() {
|
|
||||||
if it.ID == id {
|
|
||||||
return it, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DataItem{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsumerDataOf is what this module says it keeps for a provision's consumers, and whether it says.
|
|
||||||
func (m Manifest) ConsumerDataOf(provision string) (ConsumerData, bool) {
|
|
||||||
if m.Data == nil {
|
|
||||||
return ConsumerData{}, false
|
|
||||||
}
|
|
||||||
c, ok := m.Data.Consumers[provision]
|
|
||||||
return c, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeptByOf is how precious what this module keeps with a provision's provider is, and whether it says.
|
|
||||||
func (m Manifest) KeptByOf(provision string) (KeptData, bool) {
|
|
||||||
if m.Data == nil {
|
|
||||||
return KeptData{}, false
|
|
||||||
}
|
|
||||||
k, ok := m.Data.KeptBy[provision]
|
|
||||||
return k, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// keepsByClass is whether a class keeps something a binding must not move away from.
|
|
||||||
func keepsByClass(class string) bool {
|
|
||||||
return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable
|
|
||||||
}
|
|
||||||
|
|
||||||
// dataID is what an item's id may be: it is a token in a condition's key and a word on a line.
|
|
||||||
var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
// dataPathRef is an item's path: one of the module's directories or accesses, and optionally a path
|
|
||||||
// beneath it.
|
|
||||||
var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`)
|
|
||||||
|
|
||||||
// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at
|
|
||||||
// registration as every other per-manifest problem is. Whether a module declares what it should is
|
|
||||||
// the catalogue check's (DataProblems), because a module already running was written before it.
|
|
||||||
func (m Manifest) dataProblems() []string {
|
|
||||||
if m.Data == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
dirs := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
dirs[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accesses := map[string]bool{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
accesses[a.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ids := map[string]DataItem{}
|
|
||||||
paths := map[string]string{}
|
|
||||||
for i, it := range m.Data.Own {
|
|
||||||
label := it.ID
|
|
||||||
if label == "" {
|
|
||||||
label = fmt.Sprintf("item %d", i+1)
|
|
||||||
}
|
|
||||||
if !dataID.MatchString(it.ID) {
|
|
||||||
say("%s has no usable id: lower-case letters, digits and dashes", label)
|
|
||||||
} else if _, twice := ids[it.ID]; twice {
|
|
||||||
say("%s is declared twice", it.ID)
|
|
||||||
}
|
|
||||||
ids[it.ID] = it
|
|
||||||
ref := dataPathRef.FindStringSubmatch(it.Path)
|
|
||||||
switch {
|
|
||||||
case ref == nil:
|
|
||||||
say("%s's path %q is not one of the module's directories or accesses: ${dir:<id>} or ${access:<id>}, "+
|
|
||||||
"or a path beneath one (a definition names no machine path, novox/hq ADR 0112)", label, it.Path)
|
|
||||||
case ref[1] == "dir" && !dirs[ref[2]]:
|
|
||||||
say("%s's path names ${dir:%s}, and %s declares no directory %q", label, ref[2], m.Module, ref[2])
|
|
||||||
case ref[1] == "access" && !accesses[ref[2]]:
|
|
||||||
say("%s's path names ${access:%s}, and %s declares no access %q", label, ref[2], m.Module, ref[2])
|
|
||||||
case strings.Contains(it.Path, ".."):
|
|
||||||
say("%s's path %q climbs out of its directory", label, it.Path)
|
|
||||||
}
|
|
||||||
if other, twice := paths[it.Path]; twice && it.Path != "" {
|
|
||||||
say("%s and %s name the same path %s", other, label, it.Path)
|
|
||||||
}
|
|
||||||
paths[it.Path] = label
|
|
||||||
switch it.Class {
|
|
||||||
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
|
|
||||||
case ClassNone:
|
|
||||||
say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+
|
|
||||||
"that is disposable is cache", label)
|
|
||||||
default:
|
|
||||||
say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache",
|
|
||||||
label, it.Class)
|
|
||||||
}
|
|
||||||
if it.Class == ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" {
|
|
||||||
say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+
|
|
||||||
"copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+
|
|
||||||
"another item, or say `redundancy` with why that is enough", label)
|
|
||||||
}
|
|
||||||
if it.Class == ClassCache && it.Backup != nil && !it.Backup.None {
|
|
||||||
say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label)
|
|
||||||
}
|
|
||||||
if it.Class == ClassCache && it.Redundancy != "" {
|
|
||||||
say("%s is a cache and says it is protected by redundancy; a cache is not protected", label)
|
|
||||||
}
|
|
||||||
switch it.Measure {
|
|
||||||
case "", MeasureWalk, MeasureDataset, MeasureShallow:
|
|
||||||
default:
|
|
||||||
say("%s's measure %q is walk, dataset or shallow", label, it.Measure)
|
|
||||||
}
|
|
||||||
if _, err := ParseDataDuration(it.Within); err != nil {
|
|
||||||
say("%s's within: %v", label, err)
|
|
||||||
}
|
|
||||||
if _, err := ParseDataDuration(it.Active); err != nil {
|
|
||||||
say("%s's active: %v", label, err)
|
|
||||||
}
|
|
||||||
if it.Within != "" && !it.BackedUp() {
|
|
||||||
say("%s states within, and is not backed up", label)
|
|
||||||
}
|
|
||||||
if it.Active != "" && !Watched(it.Class) {
|
|
||||||
say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Dumps go into an item of the same module, declared, and not into themselves.
|
|
||||||
for _, it := range m.Data.Own {
|
|
||||||
if it.Backup == nil || it.Backup.Copy || it.Backup.None {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch into, ok := ids[it.Backup.Into]; {
|
|
||||||
case strings.TrimSpace(it.Backup.Dump) == "":
|
|
||||||
say("%s's backup names no dump command", it.ID)
|
|
||||||
case it.Backup.Into == "":
|
|
||||||
say("%s's dump says no item it writes into", it.ID)
|
|
||||||
case !ok:
|
|
||||||
say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into)
|
|
||||||
case into.ID == it.ID:
|
|
||||||
say("%s's dump writes into itself; a dump is copied from where it lands", it.ID)
|
|
||||||
case into.Class == ClassCache:
|
|
||||||
say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID)
|
|
||||||
}
|
|
||||||
if it.Backup.Dump != "" {
|
|
||||||
declared := map[string]string{}
|
|
||||||
for d := range dirs {
|
|
||||||
declared[d] = ""
|
|
||||||
}
|
|
||||||
if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil {
|
|
||||||
say("%s's dump: %v", it.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provided := map[string]bool{}
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
provided[o.Name] = true
|
|
||||||
}
|
|
||||||
wants := map[string]bool{}
|
|
||||||
for _, w := range m.Wants() {
|
|
||||||
wants[w] = true
|
|
||||||
}
|
|
||||||
for _, provision := range sortedKeys(m.Data.Consumers) {
|
|
||||||
c := m.Data.Consumers[provision]
|
|
||||||
if !provided[provision] {
|
|
||||||
say("says what it keeps for the consumers of %q, which it does not provide", provision)
|
|
||||||
}
|
|
||||||
switch c.Class {
|
|
||||||
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache, ClassNone:
|
|
||||||
default:
|
|
||||||
say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case c.Class == ClassNone && c.In != "":
|
|
||||||
say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In)
|
|
||||||
case keepsByClass(c.Class) && c.In == "":
|
|
||||||
say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+
|
|
||||||
"provision it requires", provision, c.Class)
|
|
||||||
case c.In != "":
|
|
||||||
if own, ok := ids[c.In]; ok {
|
|
||||||
if c.Class == ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" {
|
|
||||||
say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In)
|
|
||||||
}
|
|
||||||
if classRank[own.Class] < classRank[c.Class] {
|
|
||||||
say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class)
|
|
||||||
}
|
|
||||||
} else if !wants[c.In] {
|
|
||||||
say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+
|
|
||||||
"requires", provision, c.In)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, provision := range sortedKeys(m.Data.KeptBy) {
|
|
||||||
k := m.Data.KeptBy[provision]
|
|
||||||
if !wants[provision] {
|
|
||||||
say("says it keeps data with the provider of %q, which it does not require", provision)
|
|
||||||
}
|
|
||||||
switch k.Class {
|
|
||||||
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
|
|
||||||
default:
|
|
||||||
say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes
|
|
||||||
// there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data.
|
|
||||||
//
|
|
||||||
// **What the data section says, it gets**: irreplaceable, valuable or rebuildable keeps; cache and none
|
|
||||||
// do not — a cache lost in a move costs speed, not data. Before the section, an offer said it with
|
|
||||||
// `keeps-consumer-data`, which is still read; unsaid in both, a provider that grants each consumer a
|
|
||||||
// credential of its own keeps that consumer's data (ADR 0232 §1). The catalogue check refuses the
|
|
||||||
// unsaid case for a provider that grants (DataProblems), so the inference is what an older definition
|
|
||||||
// on the shelf gets, never a new one.
|
|
||||||
func (m Manifest) KeepsConsumerData(provision string) bool {
|
|
||||||
if c, ok := m.ConsumerDataOf(provision); ok {
|
|
||||||
return keepsByClass(c.Class)
|
|
||||||
}
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.Name == provision && o.KeepsConsumerData != nil {
|
|
||||||
return *o.KeepsConsumerData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, grants := m.Grants[provision]
|
|
||||||
return grants
|
|
||||||
}
|
|
||||||
|
|
||||||
// NeedsBackupHolder is whether a module's data needs the machine's backup holder to be there: it keeps
|
|
||||||
// something irreplaceable — backed up by the holder, or protected by an array the holder watches. A
|
|
||||||
// module keeping only valuable or rebuildable data is backed up where a holder is, and refused nowhere
|
|
||||||
// for want of one: the standard plan, not a requirement.
|
|
||||||
func (m Manifest) NeedsBackupHolder() bool {
|
|
||||||
for _, it := range m.DataItems() {
|
|
||||||
if it.Class == ClassIrreplaceable {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- derived: the backup holder's lines ---------------------------------------------------------
|
|
||||||
|
|
||||||
// The node-backup seat's kinds (novox/hq to-be 43, ADR 0233): `backup` is what to run and which paths
|
|
||||||
// to keep, `data` every item with its class and protection, for the holder to measure and watch.
|
|
||||||
const (
|
|
||||||
BackupKindBackup = "backup"
|
|
||||||
BackupKindData = "data"
|
|
||||||
)
|
|
||||||
|
|
||||||
// derivedContributions is what a module's data section gives the backup holder: its backup lines and
|
|
||||||
// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
|
|
||||||
// copied; a cache is listed and not measured.
|
|
||||||
func (m Manifest) derivedContributions() []SeatContribution {
|
|
||||||
items := m.DataItems()
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
kept := map[string]bool{}
|
|
||||||
keep := func(path string) {
|
|
||||||
if !kept[path] {
|
|
||||||
kept[path] = true
|
|
||||||
lines = append(lines, "path "+path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, it := range items {
|
|
||||||
if !it.BackedUp() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if it.Backup.IsDump() {
|
|
||||||
lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
|
|
||||||
if into, ok := m.DataItem(it.Backup.Into); ok {
|
|
||||||
keep(into.Path)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keep(it.Path)
|
|
||||||
}
|
|
||||||
var described []string
|
|
||||||
for _, it := range items {
|
|
||||||
covered := "-"
|
|
||||||
switch {
|
|
||||||
case it.Backup.IsDump():
|
|
||||||
if into, ok := m.DataItem(it.Backup.Into); ok {
|
|
||||||
covered = into.Path
|
|
||||||
}
|
|
||||||
case it.BackedUp():
|
|
||||||
covered = it.Path
|
|
||||||
}
|
|
||||||
described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
|
|
||||||
it.Protection(), it.MeasuredBy()))
|
|
||||||
}
|
|
||||||
var out []SeatContribution
|
|
||||||
if len(lines) > 0 {
|
|
||||||
out = append(out, SeatContribution{Seat: BackupSeat, Kind: BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
|
|
||||||
}
|
|
||||||
return append(out, SeatContribution{Seat: BackupSeat, Kind: BackupKindData, Content: strings.Join(described, "\n") + "\n"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
|
|
||||||
// its data section derives. **One list**: a module that declares its data has its backup lines
|
|
||||||
// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
|
|
||||||
// refuses it — so the holder never copies two lists that disagree.
|
|
||||||
func (m Manifest) allContributions() []SeatContribution {
|
|
||||||
if m.Data == nil {
|
|
||||||
return m.Contributions
|
|
||||||
}
|
|
||||||
derived := m.derivedContributions()
|
|
||||||
out := make([]SeatContribution, 0, len(m.Contributions)+len(derived))
|
|
||||||
for _, c := range m.Contributions {
|
|
||||||
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, c)
|
|
||||||
}
|
|
||||||
return append(out, derived...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the catalogue check ------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR
|
|
||||||
// 0233), judged over the manifests given together:
|
|
||||||
//
|
|
||||||
// - a provider that grants a provision says what it keeps for that provision's consumers;
|
|
||||||
// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place;
|
|
||||||
// - nobody writes a backup line by hand: it is derived from the data section;
|
|
||||||
// - a directory a container writes, mounted whole, is a data item of some class;
|
|
||||||
// - consumer data said to live in a required provision lives where that provision's provider keeps
|
|
||||||
// its consumers' data, as preciously, when the provider is given;
|
|
||||||
// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is
|
|
||||||
// given.
|
|
||||||
//
|
|
||||||
// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf
|
|
||||||
// was written before the rule, and refusing it there would refuse the very providers whose data the
|
|
||||||
// rule protects. Each module meets it where it is written.
|
|
||||||
func DataProblems(shelf Shelf) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, name := range shelfOrder(shelf) {
|
|
||||||
m := shelf[name]
|
|
||||||
for _, provision := range sortedKeys(m.Grants) {
|
|
||||||
if _, said := m.ConsumerDataOf(provision); !said {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+
|
|
||||||
"class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.KeepsConsumerData != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+
|
|
||||||
"class (novox/hq ADR 0233)", name, o.Name, o.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, c := range m.Contributions {
|
|
||||||
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's contribution %d is a backup line written by hand; backups are derived from the data "+
|
|
||||||
"section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, dir := range writtenDirectories(m) {
|
|
||||||
if !coversDirectory(m, dir) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s mounts its directory %q into a container to be written, and declares no data in it: "+
|
|
||||||
"data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.Data == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, provision := range sortedKeys(m.Data.Consumers) {
|
|
||||||
c := m.Data.Consumers[provision]
|
|
||||||
if c.In == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, own := m.DataItem(c.In); own {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pname := range shelfOrder(shelf) {
|
|
||||||
p := shelf[pname]
|
|
||||||
pc, said := p.ConsumerDataOf(c.In)
|
|
||||||
if !said || !providesName(p, c.In) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if classRank[pc.Class] < classRank[c.Class] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+
|
|
||||||
"so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, provision := range sortedKeys(m.Data.KeptBy) {
|
|
||||||
k := m.Data.KeptBy[provision]
|
|
||||||
if k.Class != ClassIrreplaceable {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, pname := range shelfOrder(shelf) {
|
|
||||||
p := shelf[pname]
|
|
||||||
pc, said := p.ConsumerDataOf(provision)
|
|
||||||
if !said || !providesName(p, provision) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !keepsByClass(pc.Class) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+
|
|
||||||
"protected there", name, provision, pname, provision, pc.Class))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+
|
|
||||||
"on declared redundancy", name, provision, pname, pc.In))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func providesName(m Manifest, provision string) bool {
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.Name == provision {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// writtenDirectories are the module's directories a container mounts whole and may write: a volume
|
|
||||||
// `${dir:<id>}:<target>` without `:ro`, or a directory stated by an absolute path mounted the same
|
|
||||||
// way. A file beneath a directory, or a read-only mount, is configuration the mesh wrote.
|
|
||||||
func writtenDirectories(m Manifest) []string {
|
|
||||||
absolute := map[string]string{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
|
||||||
absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "container" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
vols, _ := r["volumes"].([]any)
|
|
||||||
for _, v := range vols {
|
|
||||||
s, _ := v.(string)
|
|
||||||
mount := volumeMount.FindStringSubmatch(s)
|
|
||||||
if mount == nil || volumeReadOnly(mount[3]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src := strings.TrimRight(mount[1], "/")
|
|
||||||
if ref := dirPlain.FindStringSubmatch(src); ref != nil {
|
|
||||||
seen[ref[1]] = true
|
|
||||||
} else if id, ok := absolute[src]; ok {
|
|
||||||
seen[id] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(seen))
|
|
||||||
for id := range seen {
|
|
||||||
out = append(out, id)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// volumeMount is a container's volume, `<source>:<target>[:<options>]`, its source possibly a
|
|
||||||
// `${dir:<id>}` — whose own colon is not the separator.
|
|
||||||
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
|
|
||||||
|
|
||||||
// volumeReadOnly is whether a volume's options mount it read-only.
|
|
||||||
func volumeReadOnly(options string) bool {
|
|
||||||
for _, o := range strings.Split(options, ",") {
|
|
||||||
if strings.TrimSpace(o) == "ro" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirPlain is a whole directory, `${dir:<id>}` and nothing after it.
|
|
||||||
var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`)
|
|
||||||
|
|
||||||
// coversDirectory is whether a data item names the directory, a path within it, or a directory it
|
|
||||||
// is placed beneath.
|
|
||||||
func coversDirectory(m Manifest, dir string) bool {
|
|
||||||
parents := map[string]string{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p, ok := r["path"].(string); ok {
|
|
||||||
if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") {
|
|
||||||
parents[fmt.Sprint(r["id"])] = ref[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, it := range m.DataItems() {
|
|
||||||
ref := dataPathRef.FindStringSubmatch(it.Path)
|
|
||||||
if ref == nil || ref[1] != "dir" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 {
|
|
||||||
if ref[2] == d {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
|
|
||||||
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
|
|
||||||
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
|
|
||||||
// one name whoever holds it, and the controller can tell whether anything holds it: while nothing does,
|
|
||||||
// the controller starts every walk itself, as it did before there was a delivery to own.
|
|
||||||
|
|
||||||
// DeliverySeat is the seat mesh-delivery holds.
|
|
||||||
const DeliverySeat = "mesh-delivery"
|
|
||||||
|
|
||||||
// deliveryVerbs are the delivery seat's tools: six that read, and the acts of a person, a reviewer and healer H2.
|
|
||||||
func deliveryVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
|
||||||
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
|
|
||||||
"by state, repository or group.",
|
|
||||||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
|
||||||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
|
||||||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
|
||||||
// Delivery time against the delivery budgets (novox/hq ADR 0282): what probe D16 reads, optional until its
|
|
||||||
// holder serves it.
|
|
||||||
{Name: "times", Description: "Delivery time: from a merge to every machine of its walk running the build. " +
|
|
||||||
"Each class's delivery budget (a leaf module three minutes, a core module ten), the last days' median and " +
|
|
||||||
"worst, how many within and over, the newest delivery of each class, every delivery over its budget with its " +
|
|
||||||
"longest phase, and the delivered ones whose time is not known. Given a delivery's id, its time phase by phase.",
|
|
||||||
Input: schema(map[string]string{"days": "how many days back (default 7)", "id": "one delivery's id: its phases"}, nil),
|
|
||||||
// Optional until mesh-delivery serves it: its holder lives in the catalogue (design 33 §7).
|
|
||||||
Optional: true},
|
|
||||||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
|
||||||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
|
||||||
"steps of its walk, its group and its order.",
|
|
||||||
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
|
|
||||||
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
|
|
||||||
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
|
||||||
"state, derived from its members.",
|
|
||||||
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
|
||||||
{Name: "what-if", Description: "The delivery plan a change would have, asked of the controller's planner " +
|
|
||||||
"and kept nowhere: the modules it moves and their dependents in tiers, what each machine would receive, " +
|
|
||||||
"the steps that are not an ordinary send.",
|
|
||||||
Input: schema(map[string]string{"repository": "owner/repository",
|
|
||||||
"paths": "the files it changes, comma-separated, from the repository's root",
|
|
||||||
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
|
|
||||||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
|
||||||
"of the commit's mesh statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, " +
|
|
||||||
"description and when it was set; the merge check's full verdict as the controller said it — each " +
|
|
||||||
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
|
|
||||||
"protection would let it merge, every required status being success.",
|
|
||||||
Input: schema(map[string]string{"repository": "owner/repository",
|
|
||||||
"number": "a pull request's number: its head is read",
|
|
||||||
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
|
|
||||||
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
|
|
||||||
Optional: true},
|
|
||||||
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
|
||||||
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
|
|
||||||
"lets healer H2 take for it, or none.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
|
|
||||||
"controller is asked for its check. With why.",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
{Name: "release", Description: "A person's word that a held delivery goes on: it starts delivering. With why.",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
{Name: "stop", Description: "Stop a delivery that is not final, with why; its walk is ended through the " +
|
|
||||||
"controller, and in a group every member after it is stopped too, naming it.",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
|
||||||
"past its bound. Refused for any other.",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
{Name: "retire-history", Description: "Retire held deliveries that are history, on a person's word, with " +
|
|
||||||
"why: each made from the forge's word for a merge on the trunk heard long after it happened — replayed " +
|
|
||||||
"when the holder first held the seat — with no walk of it open. Never released, delivered or put back: " +
|
|
||||||
"retired, final. One by id, refused when the rule does not take it; or every held one, of one " +
|
|
||||||
"repository when named, answering how many it retired and naming those it left held. dry \"true\" " +
|
|
||||||
"answers what it would do and changes nothing.",
|
|
||||||
Input: schema(map[string]string{"why": "why, kept with every transition",
|
|
||||||
"id": "one delivery's id; every held one when empty",
|
|
||||||
"repository": "owner/repository: only its held deliveries",
|
|
||||||
"dry": "\"true\": what it would retire, changing nothing"}, []string{"why"}, "dry"),
|
|
||||||
// Added after the seat's holder shipped (novox/hq issue 313): optional until mesh-delivery serves it.
|
|
||||||
Optional: true},
|
|
||||||
{Name: "mark-ready", Description: "A reviewer's word that a delivery's head is approved: the holder puts the " +
|
|
||||||
"label mesh/ready on its pull request and then writes \"ready at <head>\", the order the forge counts, " +
|
|
||||||
"as the mesh's own forge account, naming who reviewed it and why. Refused when the delivery's commit is " +
|
|
||||||
"no longer its pull request's head. mesh-delivery then merges it once its checks pass (novox/hq ADR 0364).",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id (owner/repository@commit): the head reviewed",
|
|
||||||
"reviewed-by": "who reviewed it, as who@node", "why": "the review's verdict in a line"},
|
|
||||||
[]string{"id", "reviewed-by", "why"}),
|
|
||||||
// Kept one more release, optional (novox/hq issue 1022): mesh-delivery no longer serves it, but the manifest
|
|
||||||
// it still has on the mesh does until its catalogue member merges, and a seat refuses a holder that serves
|
|
||||||
// a verb it does not promise. The next release removes this entry.
|
|
||||||
Optional: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
|
|
||||||
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
|
|
||||||
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
|
|
||||||
// and the module holding it, and what the module says it says as itself, through the runtime that launches
|
|
||||||
// it — read as the seat's, a consumer would subscribe a subject nothing may publish.
|
|
||||||
func SeatSays(emits []string, event string) bool {
|
|
||||||
for _, e := range emits {
|
|
||||||
if e == event || topicMatches(e, event) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
|
|
||||||
// `>` or `**` the rest.
|
|
||||||
func topicMatches(pattern, event string) bool {
|
|
||||||
p, e := splitDots(pattern), splitDots(event)
|
|
||||||
for i, part := range p {
|
|
||||||
if part == ">" || part == "**" {
|
|
||||||
return len(e) > i
|
|
||||||
}
|
|
||||||
if i >= len(e) || (part != "*" && part != e[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(p) == len(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitDots(s string) []string {
|
|
||||||
var out []string
|
|
||||||
start := 0
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '.' {
|
|
||||||
out = append(out, s[start:i])
|
|
||||||
start = i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(out, s[start:])
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A directory the mesh places (novox/hq ADR 0112, to-be 27, issue 119).
|
|
||||||
//
|
|
||||||
// **A module definition names no host path.** A directory resource may omit `path`; the mesh
|
|
||||||
// resolves where it lands when the declaration is composed — `<root>/<module>/<id>`, the root a
|
|
||||||
// node's own setting with /var/lib as the default. From then on the module's own files, mounts
|
|
||||||
// and environment name the place as `${dir:<id>}`, the same shape as `${bound:…}` and
|
|
||||||
// `${secret:…}`: a fact the module asks for by name and never states.
|
|
||||||
//
|
|
||||||
// **A directory that states a path keeps it, and still answers `${dir:<id>}`.** That is the
|
|
||||||
// placement for an adopted machine: data that must sit where the predecessor already put it is
|
|
||||||
// declared with the path as the exception it is, and everything else in the module names it by
|
|
||||||
// id — so moving it later is one line, not a search.
|
|
||||||
//
|
|
||||||
// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes
|
|
||||||
// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the
|
|
||||||
// mesh's plumbing, not the module's data, and sits under `<root>/mesh/<module>`. A directory
|
|
||||||
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
|
|
||||||
// `${dir:<id>}` and states no path.
|
|
||||||
//
|
|
||||||
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
|
|
||||||
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
|
|
||||||
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
|
|
||||||
|
|
||||||
// defaultDataRoot is where module data lands when a node states no root of its own.
|
|
||||||
const defaultDataRoot = "/var/lib"
|
|
||||||
|
|
||||||
// The two places a pathless directory may name, beside its own id.
|
|
||||||
const (
|
|
||||||
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
|
|
||||||
// assignment, which every other placed thing of the module sits beneath.
|
|
||||||
placeOwn = "."
|
|
||||||
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
|
|
||||||
placeMesh = "mesh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
|
||||||
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
|
||||||
|
|
||||||
// dataRoot is the root this node keeps placed directories under.
|
|
||||||
func dataRoot(with Rendering) string {
|
|
||||||
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
return defaultDataRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
|
|
||||||
//
|
|
||||||
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
|
|
||||||
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
|
||||||
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
|
|
||||||
// one, because two ids resolving to one path is a mistake the module's own files make visible
|
|
||||||
// immediately.
|
|
||||||
//
|
|
||||||
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
|
|
||||||
// filled after the directories it can name are resolved; one level, because a directory beneath
|
|
||||||
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
|
|
||||||
func dirsFor(m Manifest, with Rendering) map[string]string {
|
|
||||||
dirs := map[string]string{}
|
|
||||||
var beneath []map[string]any
|
|
||||||
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
|
|
||||||
// malformed; here an invalid setting simply places nothing.
|
|
||||||
placed, _ := Places(m, with.Settings[m.Module])
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
if p, said := placed[id]; said {
|
|
||||||
dirs[id] = p.Path
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if path, stated := r["path"].(string); stated && path != "" {
|
|
||||||
if strings.HasPrefix(path, "${dir:") {
|
|
||||||
beneath = append(beneath, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirs[id] = strings.TrimRight(path, "/")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch place, _ := r["place"].(string); place {
|
|
||||||
case placeOwn:
|
|
||||||
dirs[id] = dataRoot(with) + "/" + m.Module
|
|
||||||
case placeMesh:
|
|
||||||
dirs[id] = dataRoot(with) + "/mesh/" + m.Module
|
|
||||||
default:
|
|
||||||
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range beneath {
|
|
||||||
path := strings.TrimRight(r["path"].(string), "/")
|
|
||||||
// A reference to no directory is left as written and refused where the resource is
|
|
||||||
// placed (dirInto), with the message that names what exists.
|
|
||||||
filled, _ := dirFill(path, dirs, m.Module)
|
|
||||||
dirs[fmt.Sprint(r["id"])] = filled
|
|
||||||
}
|
|
||||||
return dirs
|
|
||||||
}
|
|
||||||
|
|
||||||
// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or
|
|
||||||
// beginning with a placed reference — resolution makes it absolute before anything reads it.
|
|
||||||
// (unknownDirRefs is what checks the reference names a real directory.)
|
|
||||||
func placedOrAbsolute(path string) bool {
|
|
||||||
return strings.HasPrefix(path, "/") ||
|
|
||||||
(strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
|
|
||||||
func dirFill(s string, dirs map[string]string, module string) (string, error) {
|
|
||||||
var missing error
|
|
||||||
out := dirRef.ReplaceAllStringFunc(s, func(ref string) string {
|
|
||||||
id := dirRef.FindStringSubmatch(ref)[1]
|
|
||||||
path, has := dirs[id]
|
|
||||||
if !has {
|
|
||||||
missing = fmt.Errorf(
|
|
||||||
"%s says ${dir:%s}, and %s declares no directory %q. It declares %s",
|
|
||||||
module, id, module, id, orNothing(namesOfDirs(dirs)))
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
})
|
|
||||||
return out, missing
|
|
||||||
}
|
|
||||||
|
|
||||||
// placedManifest is the manifest with every path the mesh resolves already resolved: the maps
|
|
||||||
// naming where bindings, credentials and contributions land are filled against this node's
|
|
||||||
// placed directories, so everything downstream — the generated binding files, the sealed
|
|
||||||
// secrets, the grant directories — reads a concrete place and learns nothing new.
|
|
||||||
func placedManifest(m Manifest, with Rendering) (Manifest, error) {
|
|
||||||
dirs := dirsFor(m, with)
|
|
||||||
fillMap := func(in map[string]string) (map[string]string, error) {
|
|
||||||
if len(in) == 0 {
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
out := make(map[string]string, len(in))
|
|
||||||
for key, value := range in {
|
|
||||||
filled, err := dirFill(value, dirs, m.Module)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[key] = filled
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if m.Receives, err = fillMap(m.Receives); err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
if m.Binds, err = fillMap(m.Binds); err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
if m.Secrets, err = fillMap(m.Secrets); err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
if len(m.OwnSecrets) > 0 {
|
|
||||||
own := make(OwnSecrets, len(m.OwnSecrets))
|
|
||||||
for name, s := range m.OwnSecrets {
|
|
||||||
filled, err := dirFill(s.Path, dirs, m.Module)
|
|
||||||
if err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
own[name] = OwnSecret{Path: filled, Taken: s.Taken, IssuedBy: s.IssuedBy, Prompt: s.Prompt, Owner: s.Owner}
|
|
||||||
}
|
|
||||||
m.OwnSecrets = own
|
|
||||||
}
|
|
||||||
if m.Grants, err = fillMap(m.Grants); err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
if len(m.SecretsMany) > 0 {
|
|
||||||
many := make(map[string]map[string]string, len(m.SecretsMany))
|
|
||||||
for to, locals := range m.SecretsMany {
|
|
||||||
if many[to], err = fillMap(locals); err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.SecretsMany = many
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirInto places a resource: a pathless directory is given the path the mesh resolved for it,
|
|
||||||
// and every ${dir:…} the resource carries — in its path, its content, its mounts, its
|
|
||||||
// environment and its env-files — becomes that path.
|
|
||||||
//
|
|
||||||
// A reference naming no directory of this module is refused. Left as written, the literal
|
|
||||||
// `${dir:x}` would reach the machine as a path, and the runtime would create and mount a
|
|
||||||
// directory called `${dir:x}` — real, wrong, and named after the mistake.
|
|
||||||
func dirInto(resource map[string]any, dirs map[string]string, module string) error {
|
|
||||||
fill := func(s string) (string, error) { return dirFill(s, dirs, module) }
|
|
||||||
|
|
||||||
if fmt.Sprint(resource["type"]) == "directory" {
|
|
||||||
id := fmt.Sprint(resource["id"])
|
|
||||||
if path, stated := resource["path"].(string); !stated || path == "" {
|
|
||||||
resource["path"] = dirs[id]
|
|
||||||
}
|
|
||||||
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
|
||||||
// such field — resolved, the place IS the path.
|
|
||||||
delete(resource, "place")
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if path, ok := resource["path"].(string); ok {
|
|
||||||
if resource["path"], err = fill(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if content, ok := resource["content"].(string); ok {
|
|
||||||
if resource["content"], err = fill(content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nested values are rebuilt, never written into: the resource is a shallow copy of the
|
|
||||||
// manifest's own map, and the manifest is composed once per node — a fill written in place
|
|
||||||
// would leave the first node's paths inside every later composition.
|
|
||||||
if volumes, ok := resource["volumes"].([]any); ok {
|
|
||||||
filled := make([]any, len(volumes))
|
|
||||||
for i, v := range volumes {
|
|
||||||
filled[i] = v
|
|
||||||
if mount, ok := v.(string); ok {
|
|
||||||
if filled[i], err = fill(mount); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["volumes"] = filled
|
|
||||||
}
|
|
||||||
if env, ok := resource["env"].(map[string]any); ok {
|
|
||||||
filled := make(map[string]any, len(env))
|
|
||||||
for key, v := range env {
|
|
||||||
filled[key] = v
|
|
||||||
if value, ok := v.(string); ok {
|
|
||||||
if filled[key], err = fill(value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["env"] = filled
|
|
||||||
}
|
|
||||||
if files, ok := resource["env-file"].([]any); ok {
|
|
||||||
filled := make([]any, len(files))
|
|
||||||
for i, v := range files {
|
|
||||||
filled[i] = v
|
|
||||||
if path, ok := v.(string); ok {
|
|
||||||
if filled[i], err = fill(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["env-file"] = filled
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition
|
|
||||||
// declares — refused where the author is, not at composition on some later day (the same
|
|
||||||
// near-versus-far reasoning as the host's strict parse).
|
|
||||||
func (m Manifest) unknownDirRefs() []string {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
declared[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
referenced := func(s string) []string {
|
|
||||||
var ids []string
|
|
||||||
for _, match := range dirRef.FindAllStringSubmatch(s, -1) {
|
|
||||||
ids = append(ids, match[1])
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
place, said := r["place"].(string)
|
|
||||||
if !said {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says place on %v, which is not a directory — only a directory is placed",
|
|
||||||
m.Module, r["id"]))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if path, stated := r["path"].(string); stated && path != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s states both path and place on %v — a stated path IS the placement",
|
|
||||||
m.Module, r["id"]))
|
|
||||||
}
|
|
||||||
if place != placeOwn && place != placeMesh {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
|
|
||||||
"%q — where the mesh keeps what it writes for the module",
|
|
||||||
m.Module, place, r["id"], placeOwn, placeMesh))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
refuse := func(id string, where any) {
|
|
||||||
if declared[id] || seen[id] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[id] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+
|
|
||||||
"cannot place would reach the machine as a literal path",
|
|
||||||
m.Module, id, where, id))
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
for _, field := range []string{"path", "content"} {
|
|
||||||
if s, ok := r[field].(string); ok {
|
|
||||||
for _, id := range referenced(s) {
|
|
||||||
refuse(id, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
if list, ok := r[field].([]any); ok {
|
|
||||||
for _, v := range list {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
for _, id := range referenced(s) {
|
|
||||||
refuse(id, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if env, ok := r["env"].(map[string]any); ok {
|
|
||||||
for _, v := range env {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
for _, id := range referenced(s) {
|
|
||||||
refuse(id, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maps := map[string]map[string]string{
|
|
||||||
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
|
|
||||||
"own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
|
|
||||||
}
|
|
||||||
for field, entries := range maps {
|
|
||||||
for _, value := range entries {
|
|
||||||
for _, id := range referenced(value) {
|
|
||||||
refuse(id, field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for to, locals := range m.SecretsMany {
|
|
||||||
for _, value := range locals {
|
|
||||||
for _, id := range referenced(value) {
|
|
||||||
refuse(id, "secrets."+to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(problems)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfDirs(dirs map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for id := range dirs {
|
|
||||||
names = append(names, fmt.Sprintf("%q", id))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
@@ -1,731 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The account's environment and the login shell's code, composed from the modules a node runs
|
|
||||||
// (novox/hq ADR 0203, ADR 0204).
|
|
||||||
//
|
|
||||||
// **The same shape as the jails.** Every module may contribute — a toolchain its directory on PATH,
|
|
||||||
// a version manager a variable naming its home, a prompt the code that loads it — naming no node, no
|
|
||||||
// path and no file of the shell's (ADR 0112). The one module holding the matching seat places the
|
|
||||||
// result with a placeholder in its own file, and the controller fills it from every module on the
|
|
||||||
// node. A node not running a module has none of its contribution, and unassigning one takes its
|
|
||||||
// lines away at the next composition.
|
|
||||||
//
|
|
||||||
// **Two kinds of contribution, kept apart on purpose.** The environment is facts, which the
|
|
||||||
// controller writes in two standard formats — POSIX assignment and the service manager's
|
|
||||||
// environment.d — so a terminal, a script, the login shell's `execute` and a graphical session all
|
|
||||||
// read the same values (ADR 0203). Shell code is not a fact: it is text in one shell's syntax, which
|
|
||||||
// the controller sorts into a slot and pastes without reading, as it pastes a jail's stanza (ADR
|
|
||||||
// 0204).
|
|
||||||
|
|
||||||
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
|
||||||
// contributed: the account's environment, and the login shell's code.
|
|
||||||
const (
|
|
||||||
EnvironmentSeat = "node-environment"
|
|
||||||
LoginShellSeat = "node-login-shell"
|
|
||||||
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
|
|
||||||
PowerSeat = "node-power"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
|
|
||||||
const (
|
|
||||||
PathAtStart = "start"
|
|
||||||
PathAtEnd = "end"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Environment is what one module adds to the account's environment (novox/hq ADR 0203).
|
|
||||||
type Environment struct {
|
|
||||||
// Variables are names and literal values. A value may name the machine's own facts with
|
|
||||||
// ${machine:…}, resolved before anything is written, and nothing else that expands.
|
|
||||||
Variables map[string]string `json:"variables,omitempty"`
|
|
||||||
// Path is entries on the account's PATH, each at its start or its end, in the order declared.
|
|
||||||
Path []PathEntry `json:"path,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PathEntry is one directory a module puts on the account's PATH.
|
|
||||||
type PathEntry struct {
|
|
||||||
Entry string `json:"entry"`
|
|
||||||
At string `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShellCode is one piece of code a module adds to a shell's startup (novox/hq ADR 0204).
|
|
||||||
type ShellCode struct {
|
|
||||||
// For is the shell the code is written in.
|
|
||||||
For string `json:"for"`
|
|
||||||
// Slot is where it runs among the other modules' code: first, normal or last. Named rather
|
|
||||||
// than numbered, because every contributor would guess a number and a collision says nothing.
|
|
||||||
Slot string `json:"slot"`
|
|
||||||
// Code is never interpreted — it is the shell's syntax, and only the shell reads it.
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal
|
|
||||||
// at the check rather than code that silently lands in no placeholder.
|
|
||||||
var (
|
|
||||||
knownShells = []string{"zsh", "bash", "fish"}
|
|
||||||
knownSlots = []string{"first", "normal", "last"}
|
|
||||||
// sessionFiles are the two files of the graphical session's start that read no directory, so a
|
|
||||||
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
|
|
||||||
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
|
|
||||||
// display server's holder, as a shell's slots are placed by the login shell's.
|
|
||||||
sessionFiles = []string{"xinitrc", "xresources"}
|
|
||||||
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
|
|
||||||
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
|
|
||||||
powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// contributionTargets is every name a contribution's `for` may take.
|
|
||||||
func contributionTargets() []string {
|
|
||||||
out := append(append([]string(nil), knownShells...), sessionFiles...)
|
|
||||||
return append(out, powerMoments...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
|
|
||||||
// ADR 0208 §4).
|
|
||||||
func placerOf(target string) string {
|
|
||||||
if oneOf(sessionFiles, target) {
|
|
||||||
return DisplayServerSeat
|
|
||||||
}
|
|
||||||
if oneOf(powerMoments, target) {
|
|
||||||
return PowerSeat
|
|
||||||
}
|
|
||||||
return LoginShellSeat
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two renderings of the environment a holder may place (novox/hq ADR 0203, decision 3).
|
|
||||||
const (
|
|
||||||
EnvironmentPOSIX = "posix"
|
|
||||||
EnvironmentSystemd = "systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix},
|
|
||||||
// ${environment:systemd} and ${shell:<shell>:<slot>}. Loose inside the braces on purpose, so a
|
|
||||||
// misspelt key is found and refused rather than left in a file as a literal nobody reads.
|
|
||||||
var (
|
|
||||||
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
|
||||||
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
|
||||||
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
|
|
||||||
ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
|
|
||||||
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
||||||
|
|
||||||
// environmentProblems is what is wrong with this module's environment contribution, from the
|
|
||||||
// manifest alone.
|
|
||||||
func (m Manifest) environmentProblems() []string {
|
|
||||||
if m.Environment == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
|
||||||
switch {
|
|
||||||
case !variableName.MatchString(n):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
|
|
||||||
"underscore, then letters, digits and underscores", m.Module, n))
|
|
||||||
continue
|
|
||||||
case n == "PATH":
|
|
||||||
// PATH is the one variable every module shares, so no module may set it whole: a second
|
|
||||||
// setter would replace the first's entries, and the account's own PATH with them.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
|
|
||||||
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if why := literalProblem(m.Environment.Variables[n]); why != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for i, p := range m.Environment.Path {
|
|
||||||
switch {
|
|
||||||
case p.Entry == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
|
|
||||||
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
|
|
||||||
// A colon is PATH's own separator, so an entry holding one is two entries, and the
|
|
||||||
// check that it is already present would look for the wrong thing.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
|
|
||||||
case seen[p.Entry]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
|
|
||||||
default:
|
|
||||||
if why := literalProblem(p.Entry); why != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[p.Entry] = true
|
|
||||||
if p.At != PathAtStart && p.At != PathAtEnd {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
|
|
||||||
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// literalRule is why a value must be literal, said with every refusal of one.
|
|
||||||
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
|
|
||||||
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
|
|
||||||
|
|
||||||
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
|
|
||||||
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
|
|
||||||
// quoting in one and a character in the other; a line break ends the line in both.
|
|
||||||
func literalProblem(v string) string {
|
|
||||||
switch {
|
|
||||||
case strings.ContainsAny(v, `'"`):
|
|
||||||
return "holds a quote"
|
|
||||||
case strings.Contains(v, `\`):
|
|
||||||
return "holds a backslash"
|
|
||||||
case strings.ContainsAny(v, "\n\r"):
|
|
||||||
return "holds a line break"
|
|
||||||
case strings.ContainsRune(v, 0):
|
|
||||||
return "holds a NUL"
|
|
||||||
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
|
|
||||||
return "holds a $ that is not one of the machine's ${machine:…} facts"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
|
|
||||||
// itself is not judged: it is the shell's syntax, which the controller does not read.
|
|
||||||
func (m Manifest) shellProblems() []string {
|
|
||||||
var problems []string
|
|
||||||
for i, c := range m.Shell {
|
|
||||||
if !oneOf(contributionTargets(), c.For) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
|
|
||||||
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
|
|
||||||
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
|
|
||||||
}
|
|
||||||
if !oneOf(knownSlots, c.Slot) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
|
|
||||||
strings.Join(knownSlots, ", ")))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.Code) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// contributionPlaceholderProblems is every place this module's resources name the environment or
|
|
||||||
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
|
|
||||||
// a mesh does, and again at composition in the same words.
|
|
||||||
func (m Manifest) contributionPlaceholderProblems() []string {
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
problems = append(problems, placeholderProblems(m, r)...)
|
|
||||||
problems = append(problems, seatPlaceholderProblems(m, r)...)
|
|
||||||
problems = append(problems, collectedPlaceholderProblems(m, r)...)
|
|
||||||
problems = append(problems, consumersPlaceholderProblems(m, r)...)
|
|
||||||
// And every placeholder no pass fills where it stands: a misspelt namespace, a key its
|
|
||||||
// namespace cannot take, or a field its pass does not read (novox/hq issue 231).
|
|
||||||
problems = append(problems, unconsumedPlaceholders(m.Module, r)...)
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
|
|
||||||
//
|
|
||||||
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
|
|
||||||
// authorises the bus's user list: a module that does not hold the account's environment writing it
|
|
||||||
// would be a second writer of a file there is one of, and a module that does not hold the login
|
|
||||||
// shell writing every module's shell code would be a second shell.
|
|
||||||
func placeholderProblems(m Manifest, r map[string]any) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, field := range sortedKeys(r) {
|
|
||||||
s, ok := r[field].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
env := ofEnvironment.FindAllStringSubmatch(s, -1)
|
|
||||||
code := ofShell.FindAllStringSubmatch(s, -1)
|
|
||||||
if len(env)+len(code) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if field != "content" {
|
|
||||||
// Placed only where a file's bytes are, which is where every one of them is meant to go:
|
|
||||||
// a path or an owner holding several lines of shell is nothing the host could act on.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
|
|
||||||
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, e := range env {
|
|
||||||
if e[1] != EnvironmentPOSIX && e[1] != EnvironmentSystemd {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
|
|
||||||
m.Module, r["id"], e[0], EnvironmentPOSIX, EnvironmentSystemd))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(env) > 0 && !m.ClaimsSeat(EnvironmentSeat) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
|
|
||||||
"written by that seat's holder alone (novox/hq ADR 0203)",
|
|
||||||
m.Module, r["id"], env[0][0], m.Module, EnvironmentSeat))
|
|
||||||
}
|
|
||||||
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
|
|
||||||
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
|
|
||||||
// one placing the other's would be a second writer of a file there is one of.
|
|
||||||
refusedFor := map[string]bool{}
|
|
||||||
for _, c := range code {
|
|
||||||
target, slot, two := strings.Cut(c[1], ":")
|
|
||||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
|
|
||||||
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
|
|
||||||
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
|
||||||
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seat := placerOf(target)
|
|
||||||
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
refusedFor[seat] = true
|
|
||||||
if seat == PowerSeat {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
|
|
||||||
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
|
|
||||||
m.Module, r["id"], c[0], m.Module, seat))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seat == DisplayServerSeat {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
|
|
||||||
"the display server's holder alone (novox/hq ADR 0208)",
|
|
||||||
m.Module, r["id"], c[0], m.Module, seat, target))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
|
|
||||||
"placed by the login shell's holder alone (novox/hq ADR 0204)",
|
|
||||||
m.Module, r["id"], c[0], m.Module, seat))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func placeholderOf(env, code [][]string) string {
|
|
||||||
if len(env) > 0 {
|
|
||||||
return env[0][0]
|
|
||||||
}
|
|
||||||
return code[0][0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// contributedEnvironment is one node's environment, gathered and in the order it is written.
|
|
||||||
type contributedEnvironment struct {
|
|
||||||
// variables is by module in name order, each module's sorted by name.
|
|
||||||
variables []setBy
|
|
||||||
// start and end are PATH's entries in their final order, each once.
|
|
||||||
start, end []placedOn
|
|
||||||
}
|
|
||||||
|
|
||||||
type setBy struct {
|
|
||||||
module string
|
|
||||||
names []string
|
|
||||||
values map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
type placedOn struct {
|
|
||||||
module, entry string
|
|
||||||
}
|
|
||||||
|
|
||||||
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
|
|
||||||
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
|
|
||||||
func inModuleOrder(modules []Manifest) []Manifest {
|
|
||||||
out := append([]Manifest(nil), modules...)
|
|
||||||
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
|
|
||||||
// naming both. Neither is chosen: whichever was written last would win in one reader and not
|
|
||||||
// necessarily in the other, and the module that lost would not be told.
|
|
||||||
func variablesSetOnce(modules []Manifest) error {
|
|
||||||
setter := map[string]string{}
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
e := environmentOf(m)
|
|
||||||
if e == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, n := range sortedKeys(e.Variables) {
|
|
||||||
if first, taken := setter[n]; taken {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s and %s both set %s on this machine; the account has one environment, so one "+
|
|
||||||
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
|
|
||||||
}
|
|
||||||
setter[n] = m.Module
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
|
|
||||||
//
|
|
||||||
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
|
|
||||||
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
|
|
||||||
func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvironment, error) {
|
|
||||||
var env contributedEnvironment
|
|
||||||
if err := variablesSetOnce(modules); err != nil {
|
|
||||||
return env, err
|
|
||||||
}
|
|
||||||
placed := map[string]bool{}
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
e := environmentOf(m)
|
|
||||||
if e == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(e.Variables) > 0 {
|
|
||||||
set := setBy{module: m.Module, values: map[string]string{}}
|
|
||||||
for _, n := range sortedKeys(e.Variables) {
|
|
||||||
v, err := factsIn(e.Variables[n], facts, m.Module, n)
|
|
||||||
if err != nil {
|
|
||||||
return env, err
|
|
||||||
}
|
|
||||||
set.names = append(set.names, n)
|
|
||||||
set.values[n] = v
|
|
||||||
}
|
|
||||||
env.variables = append(env.variables, set)
|
|
||||||
}
|
|
||||||
for _, p := range e.Path {
|
|
||||||
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
|
|
||||||
if err != nil {
|
|
||||||
return env, err
|
|
||||||
}
|
|
||||||
if strings.Contains(entry, ":") {
|
|
||||||
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
|
|
||||||
m.Module, entry)
|
|
||||||
}
|
|
||||||
if placed[entry] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
placed[entry] = true
|
|
||||||
if p.At == PathAtEnd {
|
|
||||||
env.end = append(env.end, placedOn{m.Module, entry})
|
|
||||||
} else {
|
|
||||||
env.start = append(env.start, placedOn{m.Module, entry})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// environmentOf is a module's environment as the old form and the items of the environment collection say it: an
|
|
||||||
// item's template is the variable's value or the PATH entry, and its attributes the kind, the name and where on PATH
|
|
||||||
// (design 57 L3, issue 795). A moved contributor's items are rendered here into a collector not handed, as the
|
|
||||||
// provisioner's client renders them into one that is. An item whose text the mesh does not hold is not here; it is
|
|
||||||
// said as not placed (itemsLeftOutFor).
|
|
||||||
func environmentOf(m Manifest) *Environment {
|
|
||||||
var out *Environment
|
|
||||||
if m.Environment != nil {
|
|
||||||
out = &Environment{Variables: map[string]string{}, Path: append([]PathEntry(nil), m.Environment.Path...)}
|
|
||||||
for k, v := range m.Environment.Variables {
|
|
||||||
out.Variables[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, it := range itemsInOrder(m.Items) {
|
|
||||||
if it.Collection != EnvironmentCollection || it.Content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
out = &Environment{Variables: map[string]string{}}
|
|
||||||
}
|
|
||||||
value := strings.TrimSuffix(it.Content, "\n")
|
|
||||||
switch it.Attributes["kind"] {
|
|
||||||
case "variable":
|
|
||||||
out.Variables[it.Attributes["name"]] = value
|
|
||||||
case "path":
|
|
||||||
out.Path = append(out.Path, PathEntry{Entry: value, At: it.Attributes["at"]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
|
|
||||||
// either format is written, so both say the same thing (novox/hq ADR 0203).
|
|
||||||
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
|
|
||||||
for _, key := range machineUsed(v) {
|
|
||||||
value, has := facts[key]
|
|
||||||
if !has {
|
|
||||||
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
|
|
||||||
module, what, key, orNothing(namesOfFacts(facts)))
|
|
||||||
}
|
|
||||||
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
|
|
||||||
}
|
|
||||||
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
|
|
||||||
// take alike.
|
|
||||||
if why := literalProblem(v); why != "" {
|
|
||||||
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
|
|
||||||
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
|
|
||||||
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
|
|
||||||
//
|
|
||||||
// The start entries are written last-first: each is put in front of PATH, so the last written ends
|
|
||||||
// up first, and the result reads in module order, then the order each module declared.
|
|
||||||
func (e contributedEnvironment) posix() string {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, set := range e.variables {
|
|
||||||
fmt.Fprintf(&b, "# %s\n", set.module)
|
|
||||||
for _, n := range set.names {
|
|
||||||
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
named := ""
|
|
||||||
for i := len(e.start) - 1; i >= 0; i-- {
|
|
||||||
p := e.start[i]
|
|
||||||
if p.module != named {
|
|
||||||
fmt.Fprintf(&b, "# %s\n", p.module)
|
|
||||||
named = p.module
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
|
|
||||||
p.entry, p.entry)
|
|
||||||
}
|
|
||||||
named = ""
|
|
||||||
for _, p := range e.end {
|
|
||||||
if p.module != named {
|
|
||||||
fmt.Fprintf(&b, "# %s\n", p.module)
|
|
||||||
named = p.module
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
|
|
||||||
p.entry, p.entry)
|
|
||||||
}
|
|
||||||
if len(e.start)+len(e.end) > 0 {
|
|
||||||
b.WriteString("export PATH\n")
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
|
|
||||||
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
|
|
||||||
// once per manager start, so it needs no guard against running twice; the account's existing PATH
|
|
||||||
// sits between the start and the end entries.
|
|
||||||
func (e contributedEnvironment) systemd() string {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, set := range e.variables {
|
|
||||||
fmt.Fprintf(&b, "# %s\n", set.module)
|
|
||||||
for _, n := range set.names {
|
|
||||||
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(e.start) > 0 {
|
|
||||||
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
|
|
||||||
}
|
|
||||||
if len(e.end) > 0 {
|
|
||||||
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
|
|
||||||
func modulesOf(entries []placedOn) string {
|
|
||||||
var names []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, p := range entries {
|
|
||||||
if !seen[p.module] {
|
|
||||||
seen[p.module] = true
|
|
||||||
names = append(names, p.module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func entriesOf(entries []placedOn) string {
|
|
||||||
out := make([]string, len(entries))
|
|
||||||
for i, p := range entries {
|
|
||||||
out[i] = p.entry
|
|
||||||
}
|
|
||||||
return strings.Join(out, ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellCode is every module's code for one shell and one slot (novox/hq ADR 0204 §3): in module
|
|
||||||
// order, each module's pieces in the order it declared them, each preceded by a line naming the
|
|
||||||
// module, and empty when nothing is contributed.
|
|
||||||
func shellCode(modules []Manifest, shell, slot string) string {
|
|
||||||
return shellCodeOn(modules, shell, slot, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellCodeOn is shellCode on a machine with these facts: a moved contributor's item of the slot's collection is
|
|
||||||
// rendered in its module's place as its old form was (heldItemText), so a collector not handed keeps every line.
|
|
||||||
func shellCodeOn(modules []Manifest, shell, slot string, facts map[string]string) string {
|
|
||||||
return shellCodeFor(modules, shell, slot, facts, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// oldFormOfCollected is a file's text with each `${collected:contribution:<seat>:<kind>:…}` of a kind received as data
|
|
||||||
// read as the old `${contribution:<seat>:<kind>:…}`: where the collector is not handed, the controller renders the
|
|
||||||
// kind's items as it rendered its contributions, so the two forms place the same text (design 57 L3, issue 781).
|
|
||||||
func oldFormOfCollected(content string) string {
|
|
||||||
return ofCollected.ReplaceAllStringFunc(content, func(placeholder string) string {
|
|
||||||
name := ofCollected.FindStringSubmatch(placeholder)[1]
|
|
||||||
base, _ := splitSelector(name)
|
|
||||||
if format, is := strings.CutPrefix(base, EnvironmentCollection+":"); is {
|
|
||||||
return "${environment:" + format + "}"
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(base, "contribution:") {
|
|
||||||
return placeholder
|
|
||||||
}
|
|
||||||
seat, kind, _ := strings.Cut(strings.TrimPrefix(base, "contribution:"), ":")
|
|
||||||
if _, r, ok := receivable(seat, kind); !ok || !collectableAsText(r) {
|
|
||||||
return placeholder
|
|
||||||
}
|
|
||||||
return "${" + name + "}"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// shellCodeFor is shellCodeOn on a machine with these capabilities, which an item's `if-capability` is judged by. The
|
|
||||||
// pieces are placed by order (an item's `order` attribute, the default for the old form), then module, as the
|
|
||||||
// provisioner's client places them.
|
|
||||||
func shellCodeFor(modules []Manifest, shell, slot string, facts map[string]string, caps map[string]bool) string {
|
|
||||||
var blocks []textBlock
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
var old strings.Builder
|
|
||||||
for _, c := range m.Shell {
|
|
||||||
if c.For != shell || c.Slot != slot {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if old.Len() == 0 {
|
|
||||||
fmt.Fprintf(&old, "# %s\n", m.Module)
|
|
||||||
}
|
|
||||||
old.WriteString(c.Code)
|
|
||||||
if !strings.HasSuffix(c.Code, "\n") {
|
|
||||||
old.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blocks = append(blocks, moduleBlocks(m, "#", old.String(), heldItems(m, "shell:"+shell+":"+slot, nil, facts, caps))...)
|
|
||||||
}
|
|
||||||
return joinBlocks(blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// textBlock is a stretch of a collector's text and the order it is placed by.
|
|
||||||
type textBlock struct {
|
|
||||||
order int
|
|
||||||
text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// moduleBlocks is one module's pieces of a placeholder: what its old form made (placed by the default order), and
|
|
||||||
// each of its held items under a line naming the module, as the provisioner's client writes one per item. An item
|
|
||||||
// of the default order that follows the old form's text shares its line, as a module's item always did.
|
|
||||||
func moduleBlocks(m Manifest, comment, old string, items []heldItem) []textBlock {
|
|
||||||
var blocks []textBlock
|
|
||||||
if old != "" {
|
|
||||||
blocks = append(blocks, textBlock{OrderDefault, old})
|
|
||||||
}
|
|
||||||
for i, h := range items {
|
|
||||||
text := h.text
|
|
||||||
if i == 0 && old != "" && h.item.order() == OrderDefault {
|
|
||||||
blocks[0].text += text
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blocks = append(blocks, textBlock{h.item.order(), fmt.Sprintf("%s %s\n", comment, m.Module) + text})
|
|
||||||
}
|
|
||||||
return blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
// joinBlocks is the blocks by order, those of one order in the order given (module order).
|
|
||||||
func joinBlocks(blocks []textBlock) string {
|
|
||||||
sort.SliceStable(blocks, func(a, b int) bool { return blocks[a].order < blocks[b].order })
|
|
||||||
var b strings.Builder
|
|
||||||
for _, blk := range blocks {
|
|
||||||
b.WriteString(blk.text)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// contributionsInto fills a holder's file with the node's environment and its shell code.
|
|
||||||
//
|
|
||||||
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
|
|
||||||
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
|
|
||||||
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
|
|
||||||
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
|
|
||||||
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
|
|
||||||
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
|
|
||||||
// wrote, so a contributed piece is never scanned again.
|
|
||||||
func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool, unplaced *[]string) error {
|
|
||||||
problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...)
|
|
||||||
if problems = append(problems, collectedPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
|
||||||
return fmt.Errorf("%s", problems[0])
|
|
||||||
}
|
|
||||||
judged, err := consumersNotHanded(m, resource, with)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if judged {
|
|
||||||
resource["content"] = ofConsumers.ReplaceAllString(fmt.Sprint(resource["content"]), "")
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content = oldFormOfCollected(content)
|
|
||||||
// **An item is the provisioner client's** (design 57 L1): a collector filled here, the old way, holds the old
|
|
||||||
// forms alone, and each item of its collections is said as not placed. A collection of the new form is empty.
|
|
||||||
if unplaced != nil {
|
|
||||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(content, -1) {
|
|
||||||
if collection, is := collectionOfPlaceholder(f[1], f[2]); is {
|
|
||||||
_, selector := splitSelector(f[2])
|
|
||||||
where, _ := selectorOf(selector)
|
|
||||||
*unplaced = append(*unplaced, itemsLeftOutFor(modules, m, collection, where, facts, caps)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content = ofCollected.ReplaceAllString(content, "")
|
|
||||||
if ofEnvironment.MatchString(content) {
|
|
||||||
env, err := environmentOn(modules, facts)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
|
|
||||||
if ofEnvironment.FindStringSubmatch(placeholder)[1] == EnvironmentSystemd {
|
|
||||||
return env.systemd()
|
|
||||||
}
|
|
||||||
return env.posix()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text
|
|
||||||
// the controller does not read, so neither may be scanned after the other is in place — a
|
|
||||||
// contributed line that happened to spell the other's placeholder would be filled.
|
|
||||||
if ofContributed.MatchString(content) {
|
|
||||||
var failed error
|
|
||||||
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
|
|
||||||
found := ofContributed.FindStringSubmatch(placeholder)
|
|
||||||
first, second, _ := strings.Cut(found[2], ":")
|
|
||||||
if found[1] == BackupNamespace {
|
|
||||||
placed, problem := backupPlacer{modules, with, facts, caps, unplaced}.placed(m, found[2], false)
|
|
||||||
if problem != "" && failed == nil {
|
|
||||||
failed = fmt.Errorf("%s names %s: %s", m.Module, placeholder, problem)
|
|
||||||
}
|
|
||||||
return placed
|
|
||||||
}
|
|
||||||
if found[1] == "contribution" {
|
|
||||||
placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
|
|
||||||
if err != nil && failed == nil {
|
|
||||||
failed = err
|
|
||||||
}
|
|
||||||
if unplaced != nil {
|
|
||||||
*unplaced = append(*unplaced, left...)
|
|
||||||
}
|
|
||||||
return placed
|
|
||||||
}
|
|
||||||
return shellCodeFor(modules, first, second, facts, caps)
|
|
||||||
})
|
|
||||||
if failed != nil {
|
|
||||||
return failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["content"] = content
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a module may call an event, and what a consumer may ask for.
|
|
||||||
//
|
|
||||||
// A module names an event **locally**: `order.placed`, not a subject and not a routing key
|
|
||||||
// (design 29 §1). A consumer names the emitter and the event: `billing.order.placed`. The mesh
|
|
||||||
// derives the subject from those, so reorganising the subject space leaves every manifest correct.
|
|
||||||
//
|
|
||||||
// **Nothing checked this until every manifest in the catalogue was wrong the same way**
|
|
||||||
// (novox/hq 04-ISSUES/127). All thirty-seven kept the old bus's routing key —
|
|
||||||
// `module.<module>.<verb>` — which the derivation read as "a module called `module`", so every
|
|
||||||
// cross-module subscription in the mesh pointed at a namespace nobody publishes to. Nothing failed:
|
|
||||||
// the services started and none of them reacted. The documentation on these fields taught the old
|
|
||||||
// form too, which is why the drift was uniform rather than scattered.
|
|
||||||
|
|
||||||
// eventName is one name in a local event: lower-case, and no wildcard.
|
|
||||||
var eventName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
// The wildcards a consumer may use, spelled the mesh's way and derived to whatever the transport
|
|
||||||
// spells them as.
|
|
||||||
//
|
|
||||||
// **A manifest holds no transport token**, which is the whole point of naming locally: the bus the
|
|
||||||
// mesh runs on today spells these `*` and `#`, and the one being built spells them `*` and `>`. A
|
|
||||||
// manifest that said either would be a manifest that stopped being true when the wire changed.
|
|
||||||
const (
|
|
||||||
// OneName stands for exactly one name.
|
|
||||||
OneName = "*"
|
|
||||||
// TheRest stands for one or more names, and may only come last.
|
|
||||||
TheRest = "**"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EventProblems is what is wrong with a manifest's events.
|
|
||||||
//
|
|
||||||
// Refused at registration, because the alternative is a module that installs, starts, connects and
|
|
||||||
// reacts to nothing — and every log line says it is fine.
|
|
||||||
func EventProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
|
|
||||||
for _, e := range m.Emits {
|
|
||||||
if was, stale := staleEventForm(e, m.Module); stale {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s emits %q, which is the old bus's routing key. An event is named locally now, so "+
|
|
||||||
"write %q — the mesh derives the subject (novox/hq design 29 §1)",
|
|
||||||
m.Module, was, strings.TrimPrefix(was, "module."+m.Module+".")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(e, "module.") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+
|
|
||||||
"routing key and an event named that way derives into a namespace nobody owns",
|
|
||||||
m.Module, e))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := localName(e); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// **Its own name, never another's.** The bus enforces that a namespace belongs to the module
|
|
||||||
// it is named for, so an event named for somebody else cannot be published at all. If the
|
|
||||||
// event is about a role rather than about this module, it belongs on the seat: a name that
|
|
||||||
// is stable across whoever fills it (04-ISSUES/127).
|
|
||||||
if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s emits %q, which reads as another module's event. A module publishes under its "+
|
|
||||||
"own name only. If this is about a role rather than about %s, declare it on that "+
|
|
||||||
"seat, where the name survives the holder changing",
|
|
||||||
m.Module, e, m.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range m.Consumes {
|
|
||||||
if strings.HasPrefix(c, "module.") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+
|
|
||||||
"and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module.")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c == "#" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s consumes %q, which is the old bus's wildcard for everything. Write %q",
|
|
||||||
m.Module, c, TheRest))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := consumePattern(c); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// staleEventForm says an emitted name is this module's own old routing key, and what it was.
|
|
||||||
func staleEventForm(event, module string) (string, bool) {
|
|
||||||
return event, module != "" && strings.HasPrefix(event, "module."+module+".")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one.
|
|
||||||
//
|
|
||||||
// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable
|
|
||||||
// from a parser given one manifest. So this catches the case that actually happened — a name that
|
|
||||||
// is a *provision* the mesh defines, which is where "another module's event" comes from in practice
|
|
||||||
// — and the whole-catalogue check catches the rest.
|
|
||||||
func isAModuleNameOtherThan(first, module string) bool {
|
|
||||||
if first == module || first == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, isASeat := SeatNamed(first); isASeat {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if _, isASeat := SeatDelivering(first); isASeat {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// localName checks one event name: dot-separated names, no wildcards, nothing else.
|
|
||||||
func localName(event string) error {
|
|
||||||
if event == "" {
|
|
||||||
return fmt.Errorf("an event needs a name")
|
|
||||||
}
|
|
||||||
for _, part := range strings.Split(event, ".") {
|
|
||||||
if part == OneName || part == TheRest {
|
|
||||||
return fmt.Errorf("an emitted event names one event, so it carries no wildcard")
|
|
||||||
}
|
|
||||||
if !eventName.MatchString(part) {
|
|
||||||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards.
|
|
||||||
func consumePattern(pattern string) error {
|
|
||||||
if pattern == "" {
|
|
||||||
return fmt.Errorf("a consumed event needs an emitter and an event")
|
|
||||||
}
|
|
||||||
parts := strings.Split(pattern, ".")
|
|
||||||
for i, part := range parts {
|
|
||||||
switch {
|
|
||||||
case part == TheRest:
|
|
||||||
if i != len(parts)-1 {
|
|
||||||
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
|
|
||||||
}
|
|
||||||
case part == OneName:
|
|
||||||
case !eventName.MatchString(part):
|
|
||||||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// `**` alone is every event from every module, which the audit logger wants and says plainly.
|
|
||||||
if len(parts) == 1 && parts[0] != TheRest {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%q names an emitter and no event. Write <emitter>.<event>, or %q for every event",
|
|
||||||
pattern, TheRest)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The events a provider says about its consumers (novox/hq ADR 0224): a consumer it has failed
|
|
||||||
// without one success for minutes, and that consumer succeeding again or being withdrawn. The
|
|
||||||
// controller follows them from every module and `status` names a consumer failing until it recovers.
|
|
||||||
//
|
|
||||||
// **And what becomes of a consumer the mesh stopped asking for** (novox/hq ADR 0230): retired — its
|
|
||||||
// access disabled and its data kept — after the same answer in five passes, waiting for a person when
|
|
||||||
// more would go than the bound allows, re-enabled when asked for again, and deleted only by a person's
|
|
||||||
// `cleanup delete`. One event, its `change` saying which.
|
|
||||||
const (
|
|
||||||
ProvisionerFailing = "provisioner.failing"
|
|
||||||
ProvisionerRecovered = "provisioner.recovered"
|
|
||||||
ProvisionerRetirement = "provisioner.retirement"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProvisionerEvents are all three, in the order they are said.
|
|
||||||
var ProvisionerEvents = []string{ProvisionerFailing, ProvisionerRecovered, ProvisionerRetirement}
|
|
||||||
|
|
||||||
// EmitsAll is every event a module may publish: what it declares and, for a module that receives
|
|
||||||
// contributions — a provider, running a provisioner over them — the provider's standing events.
|
|
||||||
//
|
|
||||||
// **Derived, not declared**, because they are the mesh's rule about every provider rather than
|
|
||||||
// anything one module chose to say: a provider whose manifest forgot them would fail its consumers
|
|
||||||
// as silently as on 2026-10-05, with its announcement refused by the bus (novox/hq issue 179). Every
|
|
||||||
// grant of a module's publishing reads this, never the declared list alone.
|
|
||||||
func (m Manifest) EmitsAll() []string {
|
|
||||||
out := append([]string(nil), m.Emits...)
|
|
||||||
if len(m.Receives) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for _, e := range ProvisionerEvents {
|
|
||||||
if !slices.Contains(out, e) {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A node's files, read and managed (novox/hq ADR 0394, issue 900; superseding ADR 0310; hq to-be 41 WP9).
|
|
||||||
//
|
|
||||||
// **One seat for every file on a node.** The holder runs as the tool runner's account, the node's operator
|
|
||||||
// account (ADR 0394 §2), and reaches further through `sudo -n` where that account's rights do not. It refuses by
|
|
||||||
// name the places a secret is kept (reads only, ADR 0394 §7) and the paths a module owns, unless a write names a reason (`override`).
|
|
||||||
// node-reads stays beside it until mesh-files is assigned on every node (ADR 0394 §9); it goes in the next
|
|
||||||
// delivery.
|
|
||||||
|
|
||||||
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
|
|
||||||
const FilesSeat = "node-files"
|
|
||||||
|
|
||||||
// FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from
|
|
||||||
// each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5).
|
|
||||||
const FilesOwnedPaths = "MESH_OWNED_PATHS"
|
|
||||||
|
|
||||||
const (
|
|
||||||
filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
|
|
||||||
"secret is kept, or a known credential file, is refused by name"
|
|
||||||
overrideWord = "a reason, to change a path a module owns anyway (optional)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
|
|
||||||
// with their descriptions restated for the operator's account, and the writes.
|
|
||||||
func filesVerbs() []Verb {
|
|
||||||
var verbs []Verb
|
|
||||||
for _, v := range readsVerbs() {
|
|
||||||
v.Description = strings.Replace(v.Description,
|
|
||||||
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
|
|
||||||
"", 1)
|
|
||||||
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
|
|
||||||
verbs = append(verbs, v)
|
|
||||||
}
|
|
||||||
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) Verb {
|
|
||||||
in["reason"] = "why the file is changed, for the audit trail"
|
|
||||||
in["override"] = overrideWord
|
|
||||||
return Verb{Name: name, Description: description + " Refuses a path a module owns " +
|
|
||||||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
|
||||||
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
|
|
||||||
}
|
|
||||||
return append(verbs,
|
|
||||||
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
|
|
||||||
map[string]string{"path": "an absolute path", "content": "the text to write",
|
|
||||||
"mode": "the file's mode, such as 0644 (optional)"},
|
|
||||||
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
|
|
||||||
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
|
|
||||||
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
|
|
||||||
"kept in the node's trash for 7 days.",
|
|
||||||
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
|
|
||||||
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
|
|
||||||
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
|
|
||||||
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
|
|
||||||
map[string]string{"from": "an absolute path", "to": "an absolute path"},
|
|
||||||
[]string{"from", "to"}, nil, "mv"),
|
|
||||||
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
|
|
||||||
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
|
|
||||||
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
|
|
||||||
write("mkdir", "Make a directory.",
|
|
||||||
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
|
|
||||||
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
|
|
||||||
write("chmod", "Change the mode of a file or directory.",
|
|
||||||
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
|
|
||||||
"recursive": "\"true\": also everything under a directory"},
|
|
||||||
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
|
|
||||||
write("chown", "Change the owner and group of a file or directory.",
|
|
||||||
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
|
|
||||||
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
|
|
||||||
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
|
|
||||||
Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
|
|
||||||
"original path and when it went.",
|
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
|
|
||||||
write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
|
|
||||||
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool
|
|
||||||
// runner's account, the node's operator account, so it declares no runs-as.
|
|
||||||
func FilesHolderProblems(m Manifest) []string {
|
|
||||||
if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+
|
|
||||||
"the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
|
|
||||||
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
|
|
||||||
// module's manifest places (declaredPaths). A seat's path wins where both name one.
|
|
||||||
func (r Resolution) ownedPaths() map[string]string {
|
|
||||||
owned := map[string]string{}
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
for _, p := range sortedKeys(declaredPaths(m)) {
|
|
||||||
owned[p] = m.Module + " (its own tools)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range Seats() {
|
|
||||||
for _, sp := range s.Paths {
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) {
|
|
||||||
owned[sp.Path] = m.Module + " (" + s.Name + ")"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return owned
|
|
||||||
}
|
|
||||||
|
|
||||||
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
|
|
||||||
// paths modules own here.
|
|
||||||
func (r Resolution) filesWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
|
||||||
if !m.ClaimsSeat(FilesSeat) {
|
|
||||||
return words, nil
|
|
||||||
}
|
|
||||||
places, err := r.secretPlaces(with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
places2, err := json.Marshal(places)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
owned, err := json.Marshal(r.ownedPaths())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if words == nil {
|
|
||||||
words = map[string]string{}
|
|
||||||
}
|
|
||||||
for _, w := range []string{ReadsSecretPlaces, FilesOwnedPaths} {
|
|
||||||
if _, has := words[w]; has {
|
|
||||||
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
|
|
||||||
"say (novox/hq ADR 0394 §5)", m.Module, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
words[ReadsSecretPlaces] = string(places2)
|
|
||||||
words[FilesOwnedPaths] = string(owned)
|
|
||||||
return words, nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,247 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
|
||||||
// role a node seat in the mesh's own set, so i3 and sway, xterm and foot, rofi and dmenu compete for
|
|
||||||
// a role rather than each inventing one — and a machine running two of one role is refused at
|
|
||||||
// assignment instead of found by two bars on one screen.
|
|
||||||
const (
|
|
||||||
LoginManagerSeat = "node-login-manager"
|
|
||||||
DisplayServerSeat = "node-display-server"
|
|
||||||
DisplaySessionSeat = "node-display-session"
|
|
||||||
TerminalEmulatorSeat = "node-terminal-emulator"
|
|
||||||
LauncherSeat = "node-launcher"
|
|
||||||
NotifierSeat = "node-notifier"
|
|
||||||
LockScreenSeat = "node-lock-screen"
|
|
||||||
ClipboardSeat = "node-clipboard"
|
|
||||||
BarSeat = "node-bar"
|
|
||||||
CompositorSeat = "node-compositor"
|
|
||||||
SecretServiceSeat = "node-secret-service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
|
||||||
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
|
|
||||||
// ADR 0255), the compositor and the secret
|
|
||||||
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
|
||||||
func graphicalSessionSeats() []Seat {
|
|
||||||
const decided = "novox/hq ADR 0208"
|
|
||||||
return []Seat{
|
|
||||||
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
|
||||||
"one the operator account starts by default.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
|
||||||
"where it is placed; and the layout profile in force, if one matches.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
|
||||||
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
|
||||||
"identities: list them, save the current arrangement under a name, or apply one.",
|
|
||||||
Input: withEnum(schema(map[string]string{
|
|
||||||
"action": "list, save or apply",
|
|
||||||
"name": "the profile to save or apply (save and apply only)",
|
|
||||||
}, []string{"action"}), "action", "list", "save", "apply")},
|
|
||||||
}},
|
|
||||||
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
|
|
||||||
// bindings, start-up commands, rules — placed by the session's holder, never written into
|
|
||||||
// its directory by the contributor.
|
|
||||||
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
|
|
||||||
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{
|
|
||||||
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
|
||||||
"it is visible or focused.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
|
||||||
"whether it has focus; narrowed to one workspace when named.",
|
|
||||||
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
|
||||||
"or the login shell, in a directory or the account's home.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"command": "what to run in it (optional; the login shell when absent)",
|
|
||||||
"directory": "where it starts (optional; the account's home when absent)",
|
|
||||||
}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
|
||||||
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
|
||||||
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
|
||||||
"properties": map[string]any{
|
|
||||||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
|
||||||
"description": "the lines to choose between, in order"},
|
|
||||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
|
||||||
}}},
|
|
||||||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
|
||||||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
|
||||||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
|
||||||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
|
||||||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
|
||||||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
|
||||||
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
|
|
||||||
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
|
|
||||||
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "the module whose own secret is asked for",
|
|
||||||
"secret": "the own secret's name",
|
|
||||||
"node": "the machine the module runs on",
|
|
||||||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
|
||||||
"timeout_seconds": "give up after this long (optional)",
|
|
||||||
}, []string{"module", "secret", "node", "seal_to"})},
|
|
||||||
}},
|
|
||||||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "send", Description: "Show the operator a notification.",
|
|
||||||
Input: withEnum(schema(map[string]string{
|
|
||||||
"title": "the notification's summary",
|
|
||||||
"body": "its text (optional)",
|
|
||||||
"urgency": "low, normal (the default) or critical",
|
|
||||||
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
|
||||||
{Name: "history", Description: "The notifications shown lately, newest first.",
|
|
||||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "lock", Description: "Lock the operator's session now.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
|
||||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
|
||||||
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
|
||||||
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
|
||||||
}},
|
|
||||||
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
|
|
||||||
// the holder renders in its own grammar. A module that knows something about the machine — the
|
|
||||||
// power module its battery — gives the block; the bar knows no machine's hardware.
|
|
||||||
{Name: BarSeat, Scope: ScopeNode, Decision: decided,
|
|
||||||
Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
|
|
||||||
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
|
|
||||||
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
|
||||||
// the choices by the schema rather than by a refusal.
|
|
||||||
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
|
||||||
props := s["properties"].(map[string]any)
|
|
||||||
p := props[property].(map[string]any)
|
|
||||||
p["enum"] = values
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255).
|
|
||||||
const BarKindBlock = "block"
|
|
||||||
|
|
||||||
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
|
|
||||||
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
|
|
||||||
// renders it (the shape's examples) before it is accepted.
|
|
||||||
var barShows = map[string]map[string]barOption{
|
|
||||||
// The machine's battery: its charge, and how long it lasts or takes to fill.
|
|
||||||
"battery": {"device": {kind: "text"}},
|
|
||||||
// One line a command prints, run every interval seconds: what the bar cannot know itself.
|
|
||||||
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
|
|
||||||
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
|
|
||||||
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
|
|
||||||
// holder is granted to read it and shows it however its bar shows a changing value; no file and
|
|
||||||
// no command passes between the two modules.
|
|
||||||
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
|
|
||||||
func barBlockReads(data map[string]any) []StateRead {
|
|
||||||
if data["shows"] != "state" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
options, _ := data["options"].(map[string]any)
|
|
||||||
s, ok := options["state"].(string)
|
|
||||||
if !ok || s == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
key, _ := options["key"].(string)
|
|
||||||
return []StateRead{{Read: s, Key: key}}
|
|
||||||
}
|
|
||||||
|
|
||||||
type barOption struct {
|
|
||||||
kind string // text, or seconds (a whole number from 1 to 86400)
|
|
||||||
required bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
|
|
||||||
// what it shows, and that kind's options.
|
|
||||||
//
|
|
||||||
// - bar: top or bottom;
|
|
||||||
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
|
|
||||||
// clock) — the two places every holder keeps for contributed blocks;
|
|
||||||
// - order: 0–99 within the place, 50 when absent;
|
|
||||||
// - shows: battery or command; options: what that kind takes.
|
|
||||||
func barBlockShape() *Shape {
|
|
||||||
return &Shape{
|
|
||||||
Text: true,
|
|
||||||
Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
|
||||||
{Field: "place", Values: []string{"resources", "status"}}},
|
|
||||||
Order: "order",
|
|
||||||
Check: barBlockProblems,
|
|
||||||
Reads: barBlockReads,
|
|
||||||
Examples: []map[string]any{
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "battery"},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "command",
|
|
||||||
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "state",
|
|
||||||
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func barBlockProblems(data map[string]any) []string {
|
|
||||||
var problems []string
|
|
||||||
for field := range data {
|
|
||||||
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
|
|
||||||
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shows, _ := data["shows"].(string)
|
|
||||||
takes, known := barShows[shows]
|
|
||||||
if !known {
|
|
||||||
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
|
|
||||||
strings.Join(sortedKeys(barShows), ", ")))
|
|
||||||
}
|
|
||||||
options := map[string]any{}
|
|
||||||
if raw, has := data["options"]; has {
|
|
||||||
o, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return append(problems, "a block's options are an object")
|
|
||||||
}
|
|
||||||
options = o
|
|
||||||
}
|
|
||||||
for name, v := range options {
|
|
||||||
opt, ok := takes[name]
|
|
||||||
if !ok {
|
|
||||||
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
|
|
||||||
orNothing(sortedKeys(takes))))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch opt.kind {
|
|
||||||
case "text":
|
|
||||||
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
|
|
||||||
}
|
|
||||||
case "seconds":
|
|
||||||
if _, whole := wholeIn(v, 1, 86400); !whole {
|
|
||||||
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, name := range sortedKeys(takes) {
|
|
||||||
if _, has := options[name]; takes[name].required && !has {
|
|
||||||
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,802 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module handed to the provisioner (novox/hq ADR 0349 clauses 2, 4, 6 and 17, design 57 P6, issue 672).
|
|
||||||
//
|
|
||||||
// **Marked in its manifest, one module at a time.** A module whose manifest says `"moved": true` has moved: each
|
|
||||||
// file of it that names a template is filled by the provisioner's client on the node, from the templates archive
|
|
||||||
// its build packed, and the controller fills nothing in it. Every other resource of the module, and every file of
|
|
||||||
// a module not marked, is composed exactly as before (clause 17: each file the old way or the new way, never both).
|
|
||||||
//
|
|
||||||
// **The build keeps what the client needs.** Resolving a moved module's build keeps, beside the files' text the
|
|
||||||
// mesh holds, where its templates archive is and what it hashes to, and which template each file is filled from
|
|
||||||
// (Handed). The text stays, so a machine whose node-engine cannot take a handed file is still sent the module the
|
|
||||||
// old way, byte for byte.
|
|
||||||
//
|
|
||||||
// **Handed only where the machine can take it.** A node's declaration hands a moved module's files over only when
|
|
||||||
// its node-engine writes a filled file on the client's word alone (Rendering.Hands) and the node runs a provisioner
|
|
||||||
// client (Rendering.Provisioner): then each such file is declared with its path, mode, owner and who owns it at
|
|
||||||
// runtime and without its text, the declaration names it among the filled files, and the module's plan says the
|
|
||||||
// client where its templates are and the value of every placement placeholder they name. Anywhere else the module
|
|
||||||
// composes as if it were not marked.
|
|
||||||
|
|
||||||
// MovedField is the manifest's key marking a module moved.
|
|
||||||
const MovedField = "moved"
|
|
||||||
|
|
||||||
// Handed is what a moved module's build kept for the provisioner (novox/hq ADR 0349 §2): its templates archive, as
|
|
||||||
// recorded (store-relative, routed at composition like any archive), and its digest; and, for each file resource
|
|
||||||
// filled from a template, that template's path inside the archive. Written by Resolve, never by hand.
|
|
||||||
type Handed struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
Templates map[string]string `json:"templates"`
|
|
||||||
// Items is, for each item the module contributes, by ItemKey (its collection, and `/<name>` for a named one),
|
|
||||||
// that item's template inside the archive (collections.go, design 57 L1).
|
|
||||||
Items map[string]string `json:"items,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan is a moved module's plan on one node: what the node's provisioner client fills its files from. The shape
|
|
||||||
// of the assignments mirror's `provisioning` (mesh-controller internal/inventory Provisioning).
|
|
||||||
type Plan struct {
|
|
||||||
Templates PlanTemplates `json:"templates"`
|
|
||||||
Files []PlanFile `json:"files"`
|
|
||||||
Placement map[string]string `json:"placement,omitempty"`
|
|
||||||
// Secrets are the module's own secrets this declaration names (design 57 V4, issue 647): each its templates
|
|
||||||
// name, and each it keeps in a file of its own, with that file's path. By name, never a value: the client asks
|
|
||||||
// the vault for each, and the vault hands one only when the plan names it.
|
|
||||||
Secrets []PlanSecret `json:"secrets,omitempty"`
|
|
||||||
// Items are the module's items of collections (design 57 L1): each filled by the client like a file and recorded
|
|
||||||
// in mesh-provisioner_collect for its node. Collections are the collections its files name, as their collector,
|
|
||||||
// each with its comment. By template and name, never a contributor's text.
|
|
||||||
Items []PlanItem `json:"items,omitempty"`
|
|
||||||
Collections []PlanCollection `json:"collections,omitempty"`
|
|
||||||
// Provisions are what it requires here, each with the provider resolution chose to serve it (issue 760), so the
|
|
||||||
// vault sees a consumer bind to a provider and unbind from it (novox/hq ADR 0349 clause 8); and, for a
|
|
||||||
// credential the vault keeps for it alone, the name its files call it and the file holding it (issue 757). By
|
|
||||||
// name, never a value.
|
|
||||||
Provisions []PlanProvision `json:"provisions,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanItem is one item the module contributes: to which collection, and its template inside the archive.
|
|
||||||
//
|
|
||||||
// A named item has its Name and Attributes (design 57 L3, issue 777): the client records them with the item and
|
|
||||||
// places it under a placeholder selecting by them. An item whose `if-capability` the machine lacks is not in the plan.
|
|
||||||
type PlanItem struct {
|
|
||||||
Collection string `json:"collection"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Attributes map[string]string `json:"attributes,omitempty"`
|
|
||||||
Template string `json:"template"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanCollection is one collection the module collects, and the comment introducing each contributor's item.
|
|
||||||
type PlanCollection struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Comment string `json:"comment,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanProvision is one provision the module requires, under one local name, and the provider serving it: the module
|
|
||||||
// (ProviderModule) on the machine (Provider) that resolution chose for this consumer. Where several providers offer
|
|
||||||
// a mesh-scoped provision, it is the one this consumer is bound to (a pin, the seat's holder, or where its data is),
|
|
||||||
// never the others (issue 760). Local is the name the consumer keeps the credential under where it keeps several
|
|
||||||
// from one requirement (ADR 0094), empty for the ordinary one; a consumer has one provider per provision and local
|
|
||||||
// name.
|
|
||||||
//
|
|
||||||
// Name and Path are set only for a credential the vault keeps for this holder alone, which its machine's client
|
|
||||||
// fills (issue 757): the value the provider's software issued for this module on this machine. Name is what the
|
|
||||||
// module's files call it (`${secret:<name>}`) and Path the file holding it alone.
|
|
||||||
//
|
|
||||||
// CredentialForm is the form the provider's offer declares for its credential (Manifest.CredentialFormOf, issue
|
|
||||||
// 768): the vault makes a first value in that form for a provider it holds no row of. Empty where the provider's
|
|
||||||
// manifest was not in hand, and the vault then makes nothing.
|
|
||||||
type PlanProvision struct {
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Provision string `json:"provision"`
|
|
||||||
Local string `json:"local,omitempty"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
ProviderModule string `json:"provider-module"`
|
|
||||||
CredentialForm string `json:"credential-form,omitempty"`
|
|
||||||
// CredentialTool is, for an offer whose credential the provider's software issues (issue 928), the provider's tool
|
|
||||||
// the vault asks for each holder's value; empty for any other form.
|
|
||||||
CredentialTool string `json:"credential-tool,omitempty"`
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
// Contributes is what the consumer's manifest tells its provider for this provision (Manifest.Contributes: a
|
|
||||||
// database's name, its extensions), so the provider's helper learns it from the bus (issue 776). Nil where the
|
|
||||||
// manifest contributes nothing for it, and the entry is then as it was.
|
|
||||||
Contributes map[string]any `json:"contributes,omitempty"`
|
|
||||||
// At, From and As are what today's binding file tells the consumer of this provision (issue 788): where the
|
|
||||||
// provider is on the private network, the machine, and who the consumer is at its end (its slug first), so the
|
|
||||||
// provider's helper writes them into the consumer's entry and the client fills `${bound:<provision>:at|from|as}`
|
|
||||||
// from there. Empty where the resolution knew none.
|
|
||||||
At string `json:"at,omitempty"`
|
|
||||||
From string `json:"from,omitempty"`
|
|
||||||
As string `json:"as,omitempty"`
|
|
||||||
// Names are what the mesh calls the consumer through this provision (issue 792), keyed as its binding file names
|
|
||||||
// them (`name`, `internal-name`, `name-<local>`, `internal-name-<local>`): composed by the controller, which
|
|
||||||
// alone knows the node's domains, and carried unchanged by the provider's helper into the consumer's entry. Nil
|
|
||||||
// where the consumer contributes nothing to the provision, and the entry is then as it was.
|
|
||||||
Names map[string]string `json:"names,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
|
|
||||||
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
|
|
||||||
// out: the vault has no provider's value to make for it.
|
|
||||||
func provisionsOf(needs []Needed, module, as string, contributes map[string]map[string]any,
|
|
||||||
named map[string]bool) []PlanProvision {
|
|
||||||
var out []PlanProvision
|
|
||||||
for _, n := range needs {
|
|
||||||
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
|
||||||
CredentialForm: n.CredentialForm, CredentialTool: n.CredentialTool, Contributes: maps.Clone(contributes[n.Name]),
|
|
||||||
At: n.At, From: n.From, As: as}
|
|
||||||
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
|
|
||||||
// vault's row for this holder.
|
|
||||||
if (named[n.Name] || named[SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
|
|
||||||
p.Name = SecretLocal(n.Name, n.Local)
|
|
||||||
}
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortProvisions orders a plan's provisions by provision, then local name.
|
|
||||||
func sortProvisions(ps []PlanProvision) {
|
|
||||||
sort.SliceStable(ps, func(i, j int) bool {
|
|
||||||
if ps[i].Provision != ps[j].Provision {
|
|
||||||
return ps[i].Provision < ps[j].Provision
|
|
||||||
}
|
|
||||||
return ps[i].Local < ps[j].Local
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanSecret is one of the module's own secrets, by its name in the manifest; Path is the file holding it alone,
|
|
||||||
// when the manifest's own-secrets places one.
|
|
||||||
type PlanSecret struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanTemplates is where the templates archive is fetched from on this node, and what it hashes to.
|
|
||||||
type PlanTemplates struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanFile is one file the client fills.
|
|
||||||
type PlanFile struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Template string `json:"template"`
|
|
||||||
OwnerAtRuntime string `json:"owner-at-runtime"`
|
|
||||||
Takes string `json:"takes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// The words a placed file says who owns it at runtime with (novox/hq ADR 0349 §5).
|
|
||||||
const (
|
|
||||||
RuntimeOwned = "owned"
|
|
||||||
RuntimeSeed = "seed"
|
|
||||||
RuntimeManaged = "managed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OwnerAtRuntimeField is the key a declared file says who owns it at runtime by (the node-engine's
|
|
||||||
// `owner-at-runtime`).
|
|
||||||
const OwnerAtRuntimeField = "owner-at-runtime"
|
|
||||||
|
|
||||||
// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host
|
|
||||||
// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned.
|
|
||||||
func ownershipOf(r map[string]any) string {
|
|
||||||
switch {
|
|
||||||
case r["into"] != nil && fmt.Sprint(r["into"]) != "":
|
|
||||||
return RuntimeManaged
|
|
||||||
case r["create-once"] == true:
|
|
||||||
return RuntimeSeed
|
|
||||||
}
|
|
||||||
return RuntimeOwned
|
|
||||||
}
|
|
||||||
|
|
||||||
// takesOf is how the program reading a file takes a change, by what names the file among the module's resources:
|
|
||||||
// restart-on wins over reload-on; empty when nothing names it (the program reads it when it uses it).
|
|
||||||
func takesOf(m Manifest, id string) string {
|
|
||||||
takes := ""
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if slices.Contains(stringsIn(r["restart-on"]), id) {
|
|
||||||
return "restart-on"
|
|
||||||
}
|
|
||||||
if slices.Contains(stringsIn(r["reload-on"]), id) {
|
|
||||||
takes = "reload-on"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return takes
|
|
||||||
}
|
|
||||||
|
|
||||||
// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry
|
|
||||||
// what its build kept (as held), and Handed is the build's word, never a repository's.
|
|
||||||
func movedProblems(m Manifest) []string {
|
|
||||||
switch {
|
|
||||||
case m.Handed != nil && !m.Moved:
|
|
||||||
return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved",
|
|
||||||
m.Module)}
|
|
||||||
case m.Handed != nil && m.Build != nil:
|
|
||||||
return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+
|
|
||||||
"written by hand (novox/hq ADR 0349 §2)", m.Module)}
|
|
||||||
case m.Moved && m.Handed == nil && !m.Templated():
|
|
||||||
return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+
|
|
||||||
"from template files of its own, `\"template\": \"%s/<name>\"` (novox/hq ADR 0349 §2)", m.Module,
|
|
||||||
m.TemplatesDir())}
|
|
||||||
}
|
|
||||||
if !m.Moved {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the
|
|
||||||
// mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be
|
|
||||||
// absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their
|
|
||||||
// own.
|
|
||||||
var problems []string
|
|
||||||
if fams := m.OwnSecrets.Families(); len(fams) > 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+
|
|
||||||
"secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module,
|
|
||||||
strings.Join(fams, ", ")))
|
|
||||||
}
|
|
||||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
|
||||||
if m.OwnSecrets[name].IssuedBy == IssuedOutside {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+
|
|
||||||
"a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
|
|
||||||
// moved.
|
|
||||||
func handedBy(m Manifest, archive Built) *Handed {
|
|
||||||
if !m.Moved {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dir := m.TemplatesDir()
|
|
||||||
h := &Handed{Source: Recorded(archive.Reference), Digest: archive.Digest, Templates: map[string]string{}}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
name, has, wrong := templateOf(m.Module, dir, r)
|
|
||||||
if !has || wrong != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.Templates[fmt.Sprint(r["id"])] = strings.TrimPrefix(name, dir+"/")
|
|
||||||
}
|
|
||||||
for _, it := range m.Items {
|
|
||||||
if it.Template == "" || itemTemplateProblem(m, it.Template) != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if h.Items == nil {
|
|
||||||
h.Items = map[string]string{}
|
|
||||||
}
|
|
||||||
h.Items[it.key()] = strings.TrimPrefix(it.Template, dir+"/")
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// handing is whether this composition hands the module's templated files to the provisioner.
|
|
||||||
func (with Rendering) handing(m Manifest) bool {
|
|
||||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
|
|
||||||
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
|
|
||||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
|
||||||
// client asks the vault.
|
|
||||||
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
|
|
||||||
|
|
||||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
|
||||||
// <module>`.
|
|
||||||
const requiredByWords = "required by "
|
|
||||||
|
|
||||||
// RequiredBy is the module that requires module on this machine when nothing assigned it there, only pulled it in
|
|
||||||
// (issue 765): what its value in the assignments mirror names. Empty for a module assigned there, or not there.
|
|
||||||
func (r Resolution) RequiredBy(module string) string {
|
|
||||||
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
|
|
||||||
if by == r.Because[module] {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return by
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandedProvisionField marks, between composing a handed module's credential file for something it requires and
|
|
||||||
// placing it, one the vault keeps for this holder alone (issue 757): its value is the PlanProvision without its path.
|
|
||||||
// Never on the wire.
|
|
||||||
const HandedProvisionField = "handed-provision"
|
|
||||||
|
|
||||||
// HandedSecretField marks, between composing a module's own secret files and placing them, the one a handing
|
|
||||||
// composition hands to the provisioner: its value is the secret's name. Never on the wire.
|
|
||||||
const HandedSecretField = "handed-secret"
|
|
||||||
|
|
||||||
// handedSecretFile is a handed module's own secret file as the declaration carries it: placed, the secret's
|
|
||||||
// alone (0600 unless the manifest says otherwise), owned at runtime, without a value; the client fills it from the
|
|
||||||
// vault.
|
|
||||||
func handedSecretFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string,
|
|
||||||
placed map[string]Placement) (map[string]any, PlanSecret, error) {
|
|
||||||
name := fmt.Sprint(r[HandedSecretField])
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, v := range r {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
delete(out, HandedSecretField)
|
|
||||||
delete(out, "sealed")
|
|
||||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
|
||||||
return nil, PlanSecret{}, err
|
|
||||||
}
|
|
||||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
|
||||||
return nil, PlanSecret{}, err
|
|
||||||
}
|
|
||||||
ownerInto(out, placed)
|
|
||||||
if err := machineInto(out, facts, m.Module); err != nil {
|
|
||||||
return nil, PlanSecret{}, err
|
|
||||||
}
|
|
||||||
if out["mode"] == nil {
|
|
||||||
out["mode"] = "0600"
|
|
||||||
}
|
|
||||||
out[OwnerAtRuntimeField] = RuntimeOwned
|
|
||||||
return out, PlanSecret{Name: name, Path: fmt.Sprint(out["path"])}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// templatePlaceholder is a placeholder as a template writes it: its namespace and its key.
|
|
||||||
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
|
|
||||||
|
|
||||||
// configNamespaces are the namespaces the config domain answers: `config`, and `setting`, which the manifest's
|
|
||||||
// reading writes it as (toSettingsForm).
|
|
||||||
var configNamespaces = []string{"config", SettingField}
|
|
||||||
|
|
||||||
// SettingField is the settings form's word for a config placeholder's namespace.
|
|
||||||
const SettingField = "setting"
|
|
||||||
|
|
||||||
// mergesFile is whether a file resource says `merge` or `takes-settings`: the layers' settings are merged into it.
|
|
||||||
func mergesFile(r map[string]any) bool {
|
|
||||||
_, merges := r["merge"]
|
|
||||||
_, takes := r[TakesSettingsField]
|
|
||||||
return merges || takes
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergingIn is whether the module's resource id is a merging file (mergesFile).
|
|
||||||
func mergingIn(m Manifest, id string) bool {
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["id"]) == id {
|
|
||||||
return mergesFile(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// handedFile is a moved module's templated file as a handing declaration carries it: placed (its path and owner
|
|
||||||
// filled by placement alone), saying who owns it at runtime, without its text. And its line of the plan.
|
|
||||||
func handedFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string, placed map[string]Placement,
|
|
||||||
with Rendering) (map[string]any, PlanFile, error) {
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, v := range r {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
// **No text, and nothing filled into it**: the client fills it from the template; the declaration says where
|
|
||||||
// it goes and how it is written.
|
|
||||||
delete(out, "content")
|
|
||||||
delete(out, TrustedField)
|
|
||||||
delete(out, NamesOnPurpose)
|
|
||||||
delete(out, SecretsInEnvironment)
|
|
||||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
|
||||||
return nil, PlanFile{}, err
|
|
||||||
}
|
|
||||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
|
||||||
return nil, PlanFile{}, err
|
|
||||||
}
|
|
||||||
ownerInto(out, placed)
|
|
||||||
if err := machineInto(out, facts, m.Module); err != nil {
|
|
||||||
return nil, PlanFile{}, err
|
|
||||||
}
|
|
||||||
out[OwnerAtRuntimeField] = ownershipOf(out)
|
|
||||||
path := fmt.Sprint(out["path"])
|
|
||||||
return out, PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
|
|
||||||
OwnerAtRuntime: fmt.Sprint(out[OwnerAtRuntimeField]), Takes: takesOf(m, id)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// placementOf is the value of every placement placeholder the module's handed templates name, keyed as the
|
|
||||||
// placeholder names it (`machine:account`), each filled by the same pass that fills it in a declaration. A
|
|
||||||
// template naming a value only another domain answers — a secret, a binding, a contribution — is refused by
|
|
||||||
// name: the client fills config and placement, and the rest move with their own packages (design 57).
|
|
||||||
func placementOf(node string, m Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
|
|
||||||
with Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []PlanCollection, error) {
|
|
||||||
out := map[string]string{}
|
|
||||||
var secrets, required []string
|
|
||||||
var problems []string
|
|
||||||
var collections []PlanCollection
|
|
||||||
all := map[string]string{}
|
|
||||||
templateOf := map[string]string{}
|
|
||||||
for id, text := range texts {
|
|
||||||
all[id], templateOf[id] = text, m.Handed.Templates[id]
|
|
||||||
}
|
|
||||||
for key, text := range items {
|
|
||||||
all["item:"+key], templateOf["item:"+key] = text, m.Handed.Items[key]
|
|
||||||
}
|
|
||||||
judgedConsumers := map[string]bool{}
|
|
||||||
for _, id := range sortedKeys(all) {
|
|
||||||
item := strings.HasPrefix(id, "item:")
|
|
||||||
for _, found := range templatePlaceholder.FindAllStringSubmatch(all[id], -1) {
|
|
||||||
kind, key := found[1], found[2]
|
|
||||||
if collection, is := collectionOfPlaceholder(kind, key); is {
|
|
||||||
// **A collection its file names, read by the client from the items there** (design 57 L1).
|
|
||||||
comment, err := collectionComment(m, collection)
|
|
||||||
_, selector := splitSelector(key)
|
|
||||||
_, selErr := selectorOf(selector)
|
|
||||||
switch {
|
|
||||||
case selErr != nil && !item:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
|
||||||
templateOf[id], found[0], selErr))
|
|
||||||
case item:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is not a "+
|
|
||||||
"collector", m.Module, templateOf[id], found[0]))
|
|
||||||
case err != nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
|
||||||
templateOf[id], found[0], err))
|
|
||||||
case !slices.ContainsFunc(collections, func(c PlanCollection) bool { return c.Name == collection }):
|
|
||||||
collections = append(collections, PlanCollection{Name: collection, Comment: comment})
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case slices.Contains(configNamespaces, kind):
|
|
||||||
continue
|
|
||||||
case kind == ConsumersNamespace:
|
|
||||||
// **A provider's consumers' credentials** (ADR 0373): its machine's client fills them from the vault,
|
|
||||||
// for a provision the module provides, in a file only it reads (consumersPlaceholderProblems).
|
|
||||||
if item {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
|
||||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
|
||||||
} else if !judgedConsumers[id] {
|
|
||||||
judgedConsumers[id] = true
|
|
||||||
problems = append(problems, consumersOfTemplate(m, id, all[id])...)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
case item && (kind == "secret" || kind == "bound"):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
|
||||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
|
||||||
continue
|
|
||||||
case kind == "secret":
|
|
||||||
// **The module's own secret, by name** (design 57 V4): its machine's client asks the vault for it.
|
|
||||||
// A requirement's credential is a binding's, and moves with the providers (design 57 B1–B3).
|
|
||||||
if _, own := m.OwnSecrets.Plain()[key]; !own && moved[key] {
|
|
||||||
// **A credential it requires, from a provider that has moved** (issue 788): the client fills it
|
|
||||||
// from the vault's row for this holder, which the plan names.
|
|
||||||
if !slices.Contains(required, key) {
|
|
||||||
required = append(required, key)
|
|
||||||
}
|
|
||||||
} else if !own {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, which is no secret of its "+
|
|
||||||
"own; a credential it requires moves with its provider (design 57 B1–B3)", m.Module,
|
|
||||||
templateOf[id], found[0]))
|
|
||||||
} else if !slices.Contains(secrets, key) {
|
|
||||||
secrets = append(secrets, key)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
case kind == "bound" && moved[strings.SplitN(key, ":", 2)[0]]:
|
|
||||||
// **What its moved provider told it** (issue 788): the client fills it from the provider's entry.
|
|
||||||
continue
|
|
||||||
case !slices.Contains(PlacementKinds, kind):
|
|
||||||
if known(kind) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, and the provisioner's "+
|
|
||||||
"client fills only config, secrets, collections and placement until that domain moves "+
|
|
||||||
"(design 57)", m.Module, templateOf[id], found[0]))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := kind + ":" + key
|
|
||||||
if _, done := out[name]; done {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if kind == BackupNamespace {
|
|
||||||
// **What the machine's modules back up** (design 57 L3, issue 794): the text the old contribution
|
|
||||||
// placeholders render, from the same function, over the modules placed on this machine.
|
|
||||||
value, problem := backups.placed(m, key, item)
|
|
||||||
if problem != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %s", m.Module,
|
|
||||||
templateOf[id], found[0], problem))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[name] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := placed(node, found[0], m, dirs, accessPaths, facts, with)
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[name] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
sort.Strings(problems)
|
|
||||||
return nil, nil, nil, nil, errors.New(strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
sort.Slice(collections, func(a, b int) bool { return collections[a].Name < collections[b].Name })
|
|
||||||
return out, secrets, required, collections, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlacementKinds are the placeholder namespaces placement answers (ADR 0349 clause 2), as a plan's keys begin.
|
|
||||||
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", BackupNamespace}
|
|
||||||
|
|
||||||
// known is whether a namespace is one the mesh fills somewhere.
|
|
||||||
func known(kind string) bool {
|
|
||||||
for _, f := range fillers {
|
|
||||||
if f.namespace == kind {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// placed is one placement placeholder's value, filled by the passes that fill it in a file's content.
|
|
||||||
func placed(node, placeholder string, m Manifest, dirs, accessPaths, facts map[string]string, with Rendering) (string, error) {
|
|
||||||
r := map[string]any{"id": "placement", "type": "file", "path": "/placement", "content": placeholder}
|
|
||||||
if err := dirInto(r, dirs, m.Module); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := accessInto(r, accessPaths, m.Module); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := portInto(r, m.Module, m.Listens, with); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := seatInto(r, m.Module, with); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := machineInto(r, facts, m.Module); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
value := fmt.Sprint(r["content"])
|
|
||||||
if value == placeholder {
|
|
||||||
return "", fmt.Errorf("%s: %s is not answered by placement on %s", m.Module, placeholder, node)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// routedTemplates is the templates archive as this node fetches it, through the artifact store it reaches.
|
|
||||||
func routedTemplates(m Manifest, with Rendering) (PlanTemplates, error) {
|
|
||||||
r := map[string]any{"id": "templates", "source": m.Handed.Source}
|
|
||||||
if err := artifactsInto(r, m.Module, with); err != nil {
|
|
||||||
return PlanTemplates{}, err
|
|
||||||
}
|
|
||||||
return PlanTemplates{Source: fmt.Sprint(r["source"]), Digest: m.Handed.Digest}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handedFiles gathers, over one composition, the files handed to the provisioner and each handed module's plan.
|
|
||||||
type handedFiles struct {
|
|
||||||
paths []string
|
|
||||||
plans map[string]Plan
|
|
||||||
// secrets is each handed module's own secrets its declaration names, by name: the path of the file holding one
|
|
||||||
// alone, or "" for one only its templates name.
|
|
||||||
secrets map[string]map[string]string
|
|
||||||
// provisions is each handed module's credentials of what it requires that the vault keeps for it (issue 757).
|
|
||||||
provisions map[string][]PlanProvision
|
|
||||||
// contributes is each handed module's manifest `contributes`, by the requirement it is about (issue 776).
|
|
||||||
contributes map[string]map[string]map[string]any
|
|
||||||
// identities is who each handed module is at its providers' ends (issue 788), and required the credentials of
|
|
||||||
// what it requires that its templates name.
|
|
||||||
identities map[string]string
|
|
||||||
required map[string]map[string]bool
|
|
||||||
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
|
|
||||||
names map[string]map[string]map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// name records the names composed for a handed module through one requirement, as its binding file carries them.
|
|
||||||
func (h *handedFiles) name(module, provision string, own map[string]any) {
|
|
||||||
values := map[string]string{}
|
|
||||||
withOwnNames(values, own)
|
|
||||||
if len(values) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if h.names == nil {
|
|
||||||
h.names = map[string]map[string]map[string]string{}
|
|
||||||
}
|
|
||||||
if h.names[module] == nil {
|
|
||||||
h.names[module] = map[string]map[string]string{}
|
|
||||||
}
|
|
||||||
h.names[module][provision] = values
|
|
||||||
}
|
|
||||||
|
|
||||||
// begin starts a module's plan where this composition hands it, and answers the ids of the files handed: none for
|
|
||||||
// a module that is not handed here. The plan's templates and placement values are composed now; each file is added
|
|
||||||
// as it is composed.
|
|
||||||
//
|
|
||||||
// **Assigned or only pulled in by another** (issue 765): the plan travels in the module's value of the assignments
|
|
||||||
// mirror, `<node>.<module>`, which the client reads (inventory.Provisioning); a module pulled in has a value there of
|
|
||||||
// its own, which says what pulled it in.
|
|
||||||
func (h *handedFiles) begin(node string, m Manifest, dirs, accessPaths, facts map[string]string,
|
|
||||||
with Rendering, caps map[string]bool, needs []Needed, backups backupPlacer) (map[string]bool, error) {
|
|
||||||
if !with.handing(m) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
texts := map[string]string{}
|
|
||||||
ids := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
if _, named := m.Handed.Templates[id]; !named {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if mergesFile(r) {
|
|
||||||
// **A merging file is filled the old way** (issue 831, ADR 0349 clause 17): handing it needs the client
|
|
||||||
// to merge the layers, which nothing builds yet. Its text stays, the controller fills and merges it.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a %v and its build kept a template for it; only a file is filled "+
|
|
||||||
"from one", m.Module, id, r["type"])
|
|
||||||
}
|
|
||||||
text, _ := r["content"].(string)
|
|
||||||
texts[id] = text
|
|
||||||
ids[id] = true
|
|
||||||
}
|
|
||||||
for _, id := range sortedKeys(m.Handed.Templates) {
|
|
||||||
if !ids[id] && !mergingIn(m, id) {
|
|
||||||
return nil, fmt.Errorf("%s: its build kept a template for %s, which it does not declare", m.Module, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **Its items, by template** (design 57 L1): the client fills each and records it for this node.
|
|
||||||
items := map[string]string{}
|
|
||||||
var planned []PlanItem
|
|
||||||
for _, it := range m.Items {
|
|
||||||
template, kept := m.Handed.Items[it.key()]
|
|
||||||
if !kept {
|
|
||||||
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, it.key())
|
|
||||||
}
|
|
||||||
// **A capability the machine lacks leaves the item out of its plan** (design 57 L3): the client judges none.
|
|
||||||
if it.IfCapability != "" && !caps[it.IfCapability] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items[it.key()] = it.Content
|
|
||||||
planned = append(planned, PlanItem{Collection: it.Collection, Name: it.Name,
|
|
||||||
Attributes: it.Attributes, Template: template})
|
|
||||||
}
|
|
||||||
if len(ids) == 0 && len(planned) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
templates, err := routedTemplates(m, with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// What it requires from a provider that has moved (issue 788): its templates may name what that provider tells it.
|
|
||||||
moved := map[string]bool{}
|
|
||||||
for _, n := range needs {
|
|
||||||
if n.For == m.Module && n.ProviderMoved {
|
|
||||||
moved[n.Name] = true
|
|
||||||
// A credential kept per local name (issue 793) is named by its local in a template.
|
|
||||||
moved[SecretLocal(n.Name, n.Local)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placement, secrets, required, collections, err := placementOf(node, m, texts, items, dirs, accessPaths, facts,
|
|
||||||
with, moved, backups)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if h.identities == nil {
|
|
||||||
h.identities = map[string]string{}
|
|
||||||
h.required = map[string]map[string]bool{}
|
|
||||||
}
|
|
||||||
h.identities[m.Module] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
|
||||||
h.required[m.Module] = map[string]bool{}
|
|
||||||
for _, name := range required {
|
|
||||||
h.required[m.Module][name] = true
|
|
||||||
}
|
|
||||||
for _, name := range secrets {
|
|
||||||
h.secret(m.Module, PlanSecret{Name: name})
|
|
||||||
}
|
|
||||||
plan := Plan{Templates: templates, Items: planned, Collections: collections}
|
|
||||||
if len(placement) > 0 {
|
|
||||||
plan.Placement = placement
|
|
||||||
}
|
|
||||||
h.plans[m.Module] = plan
|
|
||||||
if len(m.Contributes) > 0 {
|
|
||||||
if h.contributes == nil {
|
|
||||||
h.contributes = map[string]map[string]map[string]any{}
|
|
||||||
}
|
|
||||||
h.contributes[m.Module] = m.Contributes
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// add is one handed file of a module, composed. A path handed twice is refused: the node-engine writes a filled
|
|
||||||
// file for exactly one resource, and the client's write names only the path (mesh-host internal/apply).
|
|
||||||
func (h *handedFiles) add(module string, f PlanFile) error {
|
|
||||||
if slices.Contains(h.paths, f.Path) {
|
|
||||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
|
||||||
"resource and one writer (novox/hq ADR 0349 §5)", module, f.Path)
|
|
||||||
}
|
|
||||||
plan := h.plans[module]
|
|
||||||
plan.Files = append(plan.Files, f)
|
|
||||||
h.plans[module] = plan
|
|
||||||
h.paths = append(h.paths, f.Path)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// secret is one of a handed module's own secrets its declaration names; a file holding it alone is a filled file.
|
|
||||||
func (h *handedFiles) secret(module string, sec PlanSecret) {
|
|
||||||
if h.secrets == nil {
|
|
||||||
h.secrets = map[string]map[string]string{}
|
|
||||||
}
|
|
||||||
if h.secrets[module] == nil {
|
|
||||||
h.secrets[module] = map[string]string{}
|
|
||||||
}
|
|
||||||
if sec.Path != "" || h.secrets[module][sec.Name] == "" {
|
|
||||||
h.secrets[module][sec.Name] = sec.Path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSecretFile is a handed module's own secret file, composed: a filled file, and its secret in the plan.
|
|
||||||
func (h *handedFiles) addSecretFile(module string, sec PlanSecret) error {
|
|
||||||
if slices.Contains(h.paths, sec.Path) {
|
|
||||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
|
||||||
"resource and one writer (novox/hq ADR 0349 §5)", module, sec.Path)
|
|
||||||
}
|
|
||||||
h.paths = append(h.paths, sec.Path)
|
|
||||||
h.secret(module, sec)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addProvisionFile is a handed module's credential file for something it requires, which the vault keeps for it
|
|
||||||
// alone (issue 757): a filled file, and the credential in the plan.
|
|
||||||
func (h *handedFiles) addProvisionFile(module string, pr PlanProvision) error {
|
|
||||||
if slices.Contains(h.paths, pr.Path) {
|
|
||||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
|
||||||
"resource and one writer (novox/hq ADR 0349 §5)", module, pr.Path)
|
|
||||||
}
|
|
||||||
h.paths = append(h.paths, pr.Path)
|
|
||||||
if h.provisions == nil {
|
|
||||||
h.provisions = map[string][]PlanProvision{}
|
|
||||||
}
|
|
||||||
h.provisions[module] = append(h.provisions[module], pr)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// finished is every plan with its secrets, by name, and what its module requires, each with its provider.
|
|
||||||
func (h *handedFiles) finished(needs []Needed) map[string]Plan {
|
|
||||||
if len(h.plans) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for module, plan := range h.plans {
|
|
||||||
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
|
|
||||||
// is that provision's entry, with its name and file (issue 757).
|
|
||||||
provisions := provisionsOf(needs, module, h.identities[module], h.contributes[module], h.required[module])
|
|
||||||
for _, credential := range h.provisions[module] {
|
|
||||||
i := slices.IndexFunc(provisions, func(p PlanProvision) bool {
|
|
||||||
return p.Provision == credential.Provision && p.Local == credential.Local
|
|
||||||
})
|
|
||||||
if i < 0 {
|
|
||||||
credential.As = h.identities[module]
|
|
||||||
provisions = append(provisions, credential)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
provisions[i].Name, provisions[i].Path = credential.Name, credential.Path
|
|
||||||
}
|
|
||||||
for i := range provisions {
|
|
||||||
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
|
|
||||||
}
|
|
||||||
sortProvisions(provisions)
|
|
||||||
plan.Provisions = provisions
|
|
||||||
h.plans[module] = plan
|
|
||||||
}
|
|
||||||
for module, named := range h.secrets {
|
|
||||||
plan, ok := h.plans[module]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
plan.Secrets = nil
|
|
||||||
for _, name := range sortedKeys(named) {
|
|
||||||
plan.Secrets = append(plan.Secrets, PlanSecret{Name: name, Path: named[name]})
|
|
||||||
}
|
|
||||||
h.plans[module] = plan
|
|
||||||
}
|
|
||||||
return h.plans
|
|
||||||
}
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §2 and §8,
|
|
||||||
// Phase B).
|
|
||||||
//
|
|
||||||
// **On the resource, beside its other fields**: one kind and its timing. The node-engine judges every
|
|
||||||
// long-running resource alive with no declaration (Phase A); this is how a module says what *ready* means
|
|
||||||
// for one — the image's own check adopted by name, an HTTP request to a declared endpoint, a TCP connect,
|
|
||||||
// a command in the container, the unit's own readiness, or one of the module's own tools.
|
|
||||||
//
|
|
||||||
// **An endpoint is named, never a port or an address**: the check follows the machine's port for that
|
|
||||||
// endpoint as the endpoint does, so a port this machine gave elsewhere moves the check with it. The
|
|
||||||
// controller composes the name into the port the machine published it on, and sends the field only to a
|
|
||||||
// node-engine that reads it (link.ReadinessContract): an older, strict engine refuses a field it does not
|
|
||||||
// know, whole.
|
|
||||||
//
|
|
||||||
// **The bounds are the record's**: an interval not under ten seconds, a timeout under the interval, at
|
|
||||||
// least two failing looks in a row, and a grace plus the failing looks within five minutes — so a
|
|
||||||
// resource broken from its start is said within the gate's ten. Refused here, near the author, and again
|
|
||||||
// by the node-engine, far away, in the same words.
|
|
||||||
|
|
||||||
// HealthField is the resource field.
|
|
||||||
const HealthField = "health"
|
|
||||||
|
|
||||||
// The kinds of check (to-be 48 §2).
|
|
||||||
const (
|
|
||||||
HealthRuntime = "runtime"
|
|
||||||
HealthHTTP = "http"
|
|
||||||
HealthTCP = "tcp"
|
|
||||||
HealthExec = "exec"
|
|
||||||
HealthUnit = "unit"
|
|
||||||
HealthTool = "tool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The bounds and the defaults (ADR 0240 rule 2).
|
|
||||||
const (
|
|
||||||
HealthIntervalDefault = 30 * time.Second
|
|
||||||
HealthIntervalFloor = 10 * time.Second
|
|
||||||
HealthTimeoutDefault = 5 * time.Second
|
|
||||||
HealthLooksDefault = 3
|
|
||||||
HealthLooksFloor = 2
|
|
||||||
HealthGraceDefault = 60 * time.Second
|
|
||||||
// HealthWithin is the most a grace and the failing looks may take together: a resource broken
|
|
||||||
// from its start is said within the gate's ten minutes with room for its judgings.
|
|
||||||
HealthWithin = 5 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// HealthRequiredFrom is when `module check` refuses a long-running resource without `health` (ADR 0240
|
|
||||||
// rule 8): six weeks after liveness was first judged live (2026-10-07), unless the catalogue's count of
|
|
||||||
// undeclared resources reached zero first — which its own counter enforces by never letting it rise.
|
|
||||||
var HealthRequiredFrom = time.Date(2026, 11, 18, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
// Health is one resource's declaration, read.
|
|
||||||
type Health struct {
|
|
||||||
Kind string
|
|
||||||
// Endpoint is the `listens` name an http or tcp check looks at.
|
|
||||||
Endpoint string
|
|
||||||
// Path, Status, Body and Scheme are an http check's: the path asked, the status expected (zero: any
|
|
||||||
// status under 400), a text the answer must hold, and http or https.
|
|
||||||
Path string
|
|
||||||
Status int
|
|
||||||
Body string
|
|
||||||
Scheme string
|
|
||||||
// Command is an exec check's command, run by the container's shell.
|
|
||||||
Command string
|
|
||||||
// Tool is a tool check's tool, one of the module's own.
|
|
||||||
Tool string
|
|
||||||
// The timing, with the defaults applied.
|
|
||||||
Interval, Timeout, Grace time.Duration
|
|
||||||
Looks int
|
|
||||||
// Needs is the provision the check exercises (to-be 48 §6): while its provider for this consumer is
|
|
||||||
// unhealthy, what this check finds is held under the provider's condition.
|
|
||||||
Needs string
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthKeys are the keys a `health` field may carry; anything else is refused by name.
|
|
||||||
var healthKeys = map[string]bool{"kind": true, "endpoint": true, "path": true, "status": true, "body": true,
|
|
||||||
"scheme": true, "command": true, "tool": true, "interval": true, "timeout": true, "looks": true,
|
|
||||||
"grace": true, "needs": true}
|
|
||||||
|
|
||||||
// healthAddressKeys are what a check may not be aimed by: a port or an address does not follow the
|
|
||||||
// machine's port for the endpoint, and a manifest is the same on every machine.
|
|
||||||
var healthAddressKeys = map[string]bool{"port": true, "address": true, "host": true, "url": true, "ip": true}
|
|
||||||
|
|
||||||
// LongRunning says whether a manifest resource stays up: a container that is no step and on no schedule,
|
|
||||||
// a service stated running, a process that is no step and on no schedule (ADR 0240 rule 1).
|
|
||||||
func LongRunning(r map[string]any) bool {
|
|
||||||
switch fmt.Sprint(r["type"]) {
|
|
||||||
case "container", "process":
|
|
||||||
if once, _ := r["run-once"].(bool); once {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return r["schedule"] == nil
|
|
||||||
case "service":
|
|
||||||
return fmt.Sprint(r["state"]) == "running"
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHealth reads a resource's `health` field, defaults applied. False when it carries none.
|
|
||||||
func ReadHealth(r map[string]any) (Health, bool, []string) {
|
|
||||||
raw, present := r[HealthField]
|
|
||||||
if !present {
|
|
||||||
return Health{}, false, nil
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
fields, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return Health{}, true, []string{fmt.Sprintf("%s: health is %T; it is an object with a kind and its timing", id, raw)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
keys := make([]string, 0, len(fields))
|
|
||||||
for k := range fields {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
switch {
|
|
||||||
case healthAddressKeys[k]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its health names a %s; a check names an endpoint the module "+
|
|
||||||
"declares under listens, by its name, so it follows the port this machine gives it (ADR 0240 rule 2)", id, k))
|
|
||||||
case !healthKeys[k]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its health says %q, which a health check does not have", id, k))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text := func(key string) string {
|
|
||||||
v, present := fields[key]
|
|
||||||
if !present {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s, ok := v.(string)
|
|
||||||
if !ok {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its health's %s is %T; it is text", id, key, v))
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
duration := func(key string, fallback time.Duration) time.Duration {
|
|
||||||
s := text(key)
|
|
||||||
if s == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
d, err := time.ParseDuration(s)
|
|
||||||
if err != nil || d < 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its health's %s is %q; it is a duration such as \"30s\"", id, key, s))
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
number := func(key string, fallback int) int {
|
|
||||||
v, present := fields[key]
|
|
||||||
if !present {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
f, ok := v.(float64)
|
|
||||||
if !ok || f != float64(int(f)) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its health's %s is %v; it is a whole number", id, key, v))
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return int(f)
|
|
||||||
}
|
|
||||||
h := Health{Kind: text("kind"), Endpoint: text("endpoint"), Path: text("path"), Body: text("body"),
|
|
||||||
Scheme: text("scheme"), Command: text("command"), Tool: text("tool"), Needs: text("needs"),
|
|
||||||
Interval: duration("interval", HealthIntervalDefault), Timeout: duration("timeout", HealthTimeoutDefault),
|
|
||||||
Grace: duration("grace", HealthGraceDefault), Looks: number("looks", HealthLooksDefault),
|
|
||||||
Status: number("status", 0)}
|
|
||||||
return h, true, problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be
|
|
||||||
// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the
|
|
||||||
// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not
|
|
||||||
// serve, and a tool check with no check of another kind beside it on the module.
|
|
||||||
func healthProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
endpoints := map[string]Listening{}
|
|
||||||
for _, l := range m.Listens {
|
|
||||||
if n := strings.TrimSpace(l.Name); n != "" {
|
|
||||||
endpoints[n] = l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tools := map[string]bool{}
|
|
||||||
for _, t := range m.Tools {
|
|
||||||
tools[t] = true
|
|
||||||
}
|
|
||||||
wants := map[string]bool{}
|
|
||||||
for _, w := range m.Wants() {
|
|
||||||
wants[w] = true
|
|
||||||
}
|
|
||||||
var toolChecks []string
|
|
||||||
otherKinds := 0
|
|
||||||
resources := m.Resources
|
|
||||||
// A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1),
|
|
||||||
// and only a module that runs as its own account has one.
|
|
||||||
own, hasOwn := OwnRuntimeHealth(m)
|
|
||||||
if hasOwn {
|
|
||||||
if m.RunsAs == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+
|
|
||||||
"(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+
|
|
||||||
"(novox/hq ADR 0366 §1)", m.Module))
|
|
||||||
hasOwn = false
|
|
||||||
} else {
|
|
||||||
resources = append(append([]map[string]any(nil), m.Resources...), own)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, r := range resources {
|
|
||||||
h, has, read := ReadHealth(r)
|
|
||||||
if !has {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ownRuntime := hasOwn && i == len(resources)-1
|
|
||||||
id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"])
|
|
||||||
where := m.Module + ": " + id
|
|
||||||
if ownRuntime {
|
|
||||||
where = m.Module + ": its runtime-health"
|
|
||||||
}
|
|
||||||
for _, p := range read {
|
|
||||||
problems = append(problems, m.Module+": "+p)
|
|
||||||
}
|
|
||||||
if !LongRunning(r) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+
|
|
||||||
"schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch h.Kind {
|
|
||||||
case HealthRuntime, HealthExec:
|
|
||||||
if typ != "container" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+
|
|
||||||
"the runtime runs it inside the container", where, typ, h.Kind))
|
|
||||||
}
|
|
||||||
case HealthUnit:
|
|
||||||
if typ == "container" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+
|
|
||||||
"or a process's own readiness", where, h.Kind))
|
|
||||||
}
|
|
||||||
case HealthHTTP, HealthTCP, HealthTool:
|
|
||||||
case "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords()))
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords()))
|
|
||||||
}
|
|
||||||
switch h.Kind {
|
|
||||||
case HealthHTTP, HealthTCP:
|
|
||||||
l, declared := endpoints[h.Endpoint]
|
|
||||||
switch {
|
|
||||||
case h.Endpoint == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+
|
|
||||||
"declares under listens, by its name", where, h.Kind))
|
|
||||||
case !declared:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+
|
|
||||||
"under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints)))
|
|
||||||
case l.At() != "tcp":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp",
|
|
||||||
where, h.Kind, h.Endpoint, l.At()))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if h.Endpoint != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+
|
|
||||||
"looks at", where, h.Kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h.Kind != HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+
|
|
||||||
"only an http check has", where, h.Kind))
|
|
||||||
}
|
|
||||||
if h.Kind == HealthHTTP {
|
|
||||||
if h.Path != "" && !strings.HasPrefix(h.Path, "/") {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path))
|
|
||||||
}
|
|
||||||
if h.Status != 0 && (h.Status < 100 || h.Status > 599) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status))
|
|
||||||
}
|
|
||||||
if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (h.Command != "") != (h.Kind == HealthExec) {
|
|
||||||
if h.Kind == HealthExec {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's exec check says no command", where))
|
|
||||||
} else {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs",
|
|
||||||
where, h.Kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h.Kind == HealthTool {
|
|
||||||
switch {
|
|
||||||
case h.Tool == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where))
|
|
||||||
case !tools[h.Tool]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)",
|
|
||||||
where, h.Tool, m.Module, orNoneWords(m.Tools)))
|
|
||||||
}
|
|
||||||
// Its own runtime being alive, judged by the node-engine, is the check the module does not run
|
|
||||||
// itself that this tool check stands beside (novox/hq ADR 0366 §2).
|
|
||||||
if !ownRuntime {
|
|
||||||
toolChecks = append(toolChecks, id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if h.Tool != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind))
|
|
||||||
}
|
|
||||||
if h.Kind != "" {
|
|
||||||
otherKinds++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h.Needs != "" && !wants[h.Needs] {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+
|
|
||||||
"the provision it exercises, among those the module requires", where, h.Needs, m.Module))
|
|
||||||
}
|
|
||||||
problems = append(problems, healthTimingProblems(where, h)...)
|
|
||||||
}
|
|
||||||
if len(toolChecks) > 0 && otherKinds == 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+
|
|
||||||
"function no endpoint shows, and only beside a check of another kind the module does not run itself "+
|
|
||||||
"(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", ")))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthTimingProblems holds a check's timing to its bounds.
|
|
||||||
func healthTimingProblems(where string, h Health) []string {
|
|
||||||
var problems []string
|
|
||||||
if h.Interval < HealthIntervalFloor {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+
|
|
||||||
"the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, HealthIntervalFloor))
|
|
||||||
}
|
|
||||||
if h.Timeout <= 0 || h.Timeout >= h.Interval {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+
|
|
||||||
"interval of %s", where, h.Timeout, h.Interval))
|
|
||||||
}
|
|
||||||
if h.Looks < HealthLooksFloor {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+
|
|
||||||
"look can be wrong (issue 277)", where, h.Looks, HealthLooksFloor))
|
|
||||||
}
|
|
||||||
if h.Grace < 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace))
|
|
||||||
}
|
|
||||||
if h.Looks >= HealthLooksFloor && h.Interval >= HealthIntervalFloor {
|
|
||||||
if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > HealthWithin {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+
|
|
||||||
"of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+
|
|
||||||
"the gate's bound", where, took, h.Grace, h.Looks, h.Interval, HealthWithin))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func healthKindsWords() string {
|
|
||||||
return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool"
|
|
||||||
}
|
|
||||||
|
|
||||||
func namedEndpointsWords(endpoints map[string]Listening) string {
|
|
||||||
if len(endpoints) == 0 {
|
|
||||||
return "it names none"
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(endpoints))
|
|
||||||
for n := range endpoints {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return "it names " + strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func orNoneWords(names []string) string {
|
|
||||||
if len(names) == 0 {
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undeclared is every long-running resource of the manifest without `health`, by id (ADR 0240 rule 8):
|
|
||||||
// what the catalogue's count counts.
|
|
||||||
func Undeclared(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if !LongRunning(r) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, has := r[HealthField]; !has {
|
|
||||||
out = append(out, fmt.Sprint(r["id"]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthChecks is every tool a module's health asks, as `<module>.<tool>`: what a machine's node-engine
|
|
||||||
// is granted to ask of its own node tools (to-be 48 §3).
|
|
||||||
func HealthChecks(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
resources := m.Resources
|
|
||||||
if own, has := OwnRuntimeHealth(m); has && m.RunsAs != "" {
|
|
||||||
resources = append(append([]map[string]any(nil), resources...), own)
|
|
||||||
}
|
|
||||||
for _, r := range resources {
|
|
||||||
h, has, _ := ReadHealth(r)
|
|
||||||
if has && h.Kind == HealthTool && h.Tool != "" && LongRunning(r) {
|
|
||||||
out = append(out, m.Module+"."+h.Tool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnRuntimeHealth is a module's `runtime-health` as the resource it is put on (novox/hq ADR 0366 §1): the
|
|
||||||
// process `<module>.own-runtime`, carrying it as its `health`. False when the module declares none.
|
|
||||||
func OwnRuntimeHealth(m Manifest) (map[string]any, bool) {
|
|
||||||
if m.RuntimeHealth == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return map[string]any{"id": m.Module + "." + OwnRuntimeID(), "type": "process", HealthField: m.RuntimeHealth}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// healthInto composes a resource's `health` into the node-engine's words, or takes it away (to-be 48 §2,
|
|
||||||
// §3): for an engine that reads it, the endpoint becomes the port this machine published it on and the
|
|
||||||
// defaults are written out; for one that does not — older and strict — the field is not sent, and the
|
|
||||||
// resource is judged by liveness alone, as before.
|
|
||||||
func healthInto(resource map[string]any, m Manifest, with Rendering) {
|
|
||||||
if _, has := resource[HealthField]; !has {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !with.ReadsHealth {
|
|
||||||
delete(resource, HealthField)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h, _, _ := ReadHealth(resource)
|
|
||||||
out := map[string]any{"kind": h.Kind, "interval": h.Interval.String(), "timeout": h.Timeout.String(),
|
|
||||||
"looks": h.Looks, "grace": h.Grace.String()}
|
|
||||||
if h.Endpoint != "" {
|
|
||||||
out["endpoint"] = h.Endpoint
|
|
||||||
if port, ok := EndpointPort(m, h.Endpoint); ok {
|
|
||||||
out["port"] = with.machinePort(m.Module, port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h.Kind == HealthHTTP {
|
|
||||||
path := h.Path
|
|
||||||
if path == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
out["path"] = path
|
|
||||||
if h.Status != 0 {
|
|
||||||
out["status"] = h.Status
|
|
||||||
}
|
|
||||||
if h.Body != "" {
|
|
||||||
out["body"] = h.Body
|
|
||||||
}
|
|
||||||
if h.Scheme != "" {
|
|
||||||
out["scheme"] = h.Scheme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if h.Command != "" {
|
|
||||||
out["command"] = h.Command
|
|
||||||
}
|
|
||||||
if h.Tool != "" {
|
|
||||||
out["tool"] = h.Tool
|
|
||||||
}
|
|
||||||
if h.Needs != "" {
|
|
||||||
out["needs"] = h.Needs
|
|
||||||
}
|
|
||||||
resource[HealthField] = out
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthWords is a declared check in a line, for `module check` and `node show`.
|
|
||||||
func HealthWords(h Health) string {
|
|
||||||
var what string
|
|
||||||
switch h.Kind {
|
|
||||||
case HealthHTTP:
|
|
||||||
what = "http " + orSlash(h.Path) + " on " + h.Endpoint
|
|
||||||
if h.Status != 0 {
|
|
||||||
what += " expecting " + strconv.Itoa(h.Status)
|
|
||||||
}
|
|
||||||
case HealthTCP:
|
|
||||||
what = "tcp on " + h.Endpoint
|
|
||||||
case HealthTool:
|
|
||||||
what = "its tool " + h.Tool
|
|
||||||
case HealthRuntime:
|
|
||||||
what = "its image's own check"
|
|
||||||
default:
|
|
||||||
what = h.Kind
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s every %s", what, h.Interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
func orSlash(p string) string {
|
|
||||||
if p == "" {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Who a consumer is, said once by the mesh (novox/hq 04-ISSUES/023).
|
|
||||||
//
|
|
||||||
// **The provisioner used to invent this and nothing else could derive it.** It made a role called
|
|
||||||
// `mesh_<node>_<module>`, which is a reasonable name and is knowable nowhere else: not by the
|
|
||||||
// control plane, not by the binding, and above all not by the consumer — which has to present it
|
|
||||||
// in order to authenticate. The one identifier needed to connect was the one thing no part of the
|
|
||||||
// mesh would say.
|
|
||||||
//
|
|
||||||
// So the mesh says it. It goes to the provider in the grant and to the consumer in its binding,
|
|
||||||
// from **one derivation**, which is what makes the two ends agree by construction rather than by
|
|
||||||
// two conventions that were the same on the day they were written.
|
|
||||||
//
|
|
||||||
// **It is still name-agnostic.** The mesh does not know what a role or an access key or a client
|
|
||||||
// is; it says who is asking, and each provisioner makes that true in whatever its own system
|
|
||||||
// calls an identity. What a provider does with it is the provider's business, as everything about
|
|
||||||
// a provision is.
|
|
||||||
|
|
||||||
// identityUnusable is every character that is not safe unquoted in the systems these names reach.
|
|
||||||
//
|
|
||||||
// Conservative on purpose: lower-case letters, digits and underscore reach a PostgreSQL role, a
|
|
||||||
// MinIO access key, an LDAP uid and a Keycloak client without quoting or escaping in any of them.
|
|
||||||
// A wider set would work in most and fail in one, discovered as a login that cannot be created.
|
|
||||||
var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`)
|
|
||||||
|
|
||||||
// IdentityPrefix marks what the mesh made, so a provisioner can find its own work and leave
|
|
||||||
// everything else alone. Withdrawal depends on it entirely.
|
|
||||||
const IdentityPrefix = "mesh_"
|
|
||||||
|
|
||||||
// IdentitySource is the name the mesh derives a consumer's identity from: the module's slug when it
|
|
||||||
// has declared one, otherwise its name (novox/hq ADR 0049). A module with a name short enough to fit
|
|
||||||
// the tightest backend needs no slug; one whose name would overflow declares a short legible one.
|
|
||||||
func IdentitySource(slug, name string) string {
|
|
||||||
if slug != "" {
|
|
||||||
return slug
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. The
|
|
||||||
// `module` argument is the identity source — a slug or a name; see IdentitySource.
|
|
||||||
//
|
|
||||||
// A dot and a dash both become an underscore, so `home-server` and `home.server` would collide —
|
|
||||||
// which cannot happen, because a machine has one name and it is either.
|
|
||||||
func ConsumerIdentity(node, module string) string {
|
|
||||||
clean := func(s string) string {
|
|
||||||
return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_")
|
|
||||||
}
|
|
||||||
return IdentityPrefix + clean(node) + "_" + clean(module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IdentityBound is the longest consumer identity one provision's backend keeps, and what keeps it
|
|
||||||
// (novox/hq ADR 0049, refined by ADR 0225). Max zero means no bound: the provision keeps no name
|
|
||||||
// derived from its consumer, or keeps one in something with no limit the mesh need respect.
|
|
||||||
type IdentityBound struct {
|
|
||||||
// Max is the longest identity that backend keeps, in characters; zero for none.
|
|
||||||
Max int `json:"max,omitempty"`
|
|
||||||
// In is what keeps it, in words a refusal can quote: "an S3 access key", "a PostgreSQL role".
|
|
||||||
In string `json:"in,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bounded is whether this bound refuses anything.
|
|
||||||
func (b IdentityBound) Bounded() bool { return b.Max > 0 }
|
|
||||||
|
|
||||||
// DefaultIdentityLimit is the bound on a provision whose provider receives its consumers and does
|
|
||||||
// not say how long a name it keeps: an S3 access key's 20 (novox/hq 04-ISSUES/010, 034), the
|
|
||||||
// tightest backend the mesh has met. It was the bound on every provision until ADR 0225; it stays
|
|
||||||
// the bound on any that has not said otherwise, because a provider that is told each consumer's
|
|
||||||
// identity may create a name from it in a backend nobody has measured.
|
|
||||||
const DefaultIdentityLimit = 20
|
|
||||||
|
|
||||||
// DefaultIdentityBound is DefaultIdentityLimit, said as a bound.
|
|
||||||
var DefaultIdentityBound = IdentityBound{Max: DefaultIdentityLimit,
|
|
||||||
In: "a backend that has not said its limit (the tightest known, an S3 access key's)"}
|
|
||||||
|
|
||||||
// identityLimit is the bound CheckIdentity applies, for a caller that does not know which provision
|
|
||||||
// the identity is for.
|
|
||||||
const identityLimit = DefaultIdentityLimit
|
|
||||||
|
|
||||||
// CheckIdentity refuses an identity that would not fit the tightest backend the mesh knows. A caller
|
|
||||||
// that knows the provision uses CheckIdentityWithin and that provision's own bound (ADR 0225).
|
|
||||||
//
|
|
||||||
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
|
|
||||||
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
|
|
||||||
// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the
|
|
||||||
// name and is the only thing that can choose another. The remedy is a first-class one: give the
|
|
||||||
// module a short `slug` (ADR 0049), or shorten the machine's name.
|
|
||||||
func CheckIdentity(node, module string) error {
|
|
||||||
return CheckIdentityWithin(node, module, IdentityBound{Max: identityLimit, In: "a backend (an S3 access key)"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckIdentityWithin refuses an identity that would not fit one provision's bound, and accepts any
|
|
||||||
// identity for a provision with none (novox/hq ADR 0225).
|
|
||||||
func CheckIdentityWithin(node, module string, bound IdentityBound) error {
|
|
||||||
if !bound.Bounded() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
got := ConsumerIdentity(node, module)
|
|
||||||
if len(got) <= bound.Max {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s on %s is identified as %q, %d characters where %s keeps %d — "+
|
|
||||||
"give the module a shorter `slug` or shorten the machine's name",
|
|
||||||
module, node, got, len(got), bound.In, bound.Max)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overflow is one consumer whose identity does not fit the provision it requires: left out of its
|
|
||||||
// provider's grants and reported, never a reason to refuse the provider's machine (ADR 0225).
|
|
||||||
type Overflow struct {
|
|
||||||
// Provision is what was required, Provider the machine answering it.
|
|
||||||
Provision string `json:"provision"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
// Consumer is the machine, Module the module on it that required it.
|
|
||||||
Consumer string `json:"consumer"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
// Identity is the name the mesh derived, and Bound what it overflows.
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Bound IdentityBound `json:"bound"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o Overflow) String() string {
|
|
||||||
return fmt.Sprintf("%s on %s requires %s from %s and is identified as %q, %d characters where %s "+
|
|
||||||
"keeps %d — left out of %s's grants until the module's `slug` is shorter",
|
|
||||||
o.Module, o.Consumer, o.Provision, o.Provider, o.Identity, len(o.Identity), o.Bound.In,
|
|
||||||
o.Bound.Max, o.Provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overflowing is every requirement of this machine's modules whose identity overflows the bound of
|
|
||||||
// the provision answering it. The same judgement the provider's composition makes before it grants
|
|
||||||
// (grantsFor), made from the consumer's side so `status` can say it about every machine.
|
|
||||||
func (r Resolution) Overflowing() []Overflow {
|
|
||||||
slugs := map[string]string{}
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
slugs[m.Module] = m.Slug
|
|
||||||
}
|
|
||||||
var out []Overflow
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, n := range r.Needs {
|
|
||||||
if n.ByRecord || !n.Identity.Bounded() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
source := IdentitySource(slugs[n.For], n.For)
|
|
||||||
if CheckIdentityWithin(r.Node, source, n.Identity) == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := n.Name + "\x00" + n.From + "\x00" + n.For
|
|
||||||
if seen[key] {
|
|
||||||
continue // one line per requirement, however many local names it has
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
out = append(out, Overflow{Provision: n.Name, Provider: n.From, Consumer: r.Node, Module: n.For,
|
|
||||||
Identity: ConsumerIdentity(r.Node, source), Bound: n.Identity})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Module != out[j].Module {
|
|
||||||
return out[i].Module < out[j].Module
|
|
||||||
}
|
|
||||||
return out[i].Provision < out[j].Provision
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultLongestMachine is the machine name the catalogue check judges identities on when it is not
|
|
||||||
// told one: the longest name of the mesh this catalogue is written for, so a catalogue that passes
|
|
||||||
// passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's;
|
|
||||||
// a mesh that names a longer machine raises this in the same change (ADR 0225).
|
|
||||||
const DefaultLongestMachine = 6
|
|
||||||
|
|
||||||
// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine
|
|
||||||
// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the
|
|
||||||
// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A
|
|
||||||
// provision no module in the shelf offers is not judged: its bound is not known here.
|
|
||||||
func IdentityProblems(shelf Shelf, longestMachine int) []string {
|
|
||||||
offeredBy := map[string][]string{}
|
|
||||||
for _, name := range shelfOrder(shelf) {
|
|
||||||
for _, o := range shelf[name].Offers() {
|
|
||||||
offeredBy[o] = append(offeredBy[o], name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
machine := strings.Repeat("n", longestMachine)
|
|
||||||
var problems []string
|
|
||||||
for _, name := range shelfOrder(shelf) {
|
|
||||||
m := shelf[name]
|
|
||||||
source := IdentitySource(m.Slug, m.Module)
|
|
||||||
for _, want := range m.Wants() {
|
|
||||||
// The tightest bound among the modules offering it: whichever one answers on a given
|
|
||||||
// machine, the identity has to fit it.
|
|
||||||
tightest, by := IdentityBound{}, ""
|
|
||||||
for _, provider := range offeredBy[want] {
|
|
||||||
if provider == name {
|
|
||||||
continue // a module answering its own requirement is not its own consumer
|
|
||||||
}
|
|
||||||
b := shelf[provider].IdentityBoundOf(want)
|
|
||||||
if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) {
|
|
||||||
tightest, by = b, provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if CheckIdentityWithin(machine, source, tightest) == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
got := ConsumerIdentity(machine, source)
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+
|
|
||||||
"on a machine with a %d-character name it is identified as %q, %d — give %s a "+
|
|
||||||
"`slug` of at most %d characters",
|
|
||||||
name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name,
|
|
||||||
tightest.Max-len(IdentityPrefix)-longestMachine-1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
|
|
||||||
//
|
|
||||||
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
|
|
||||||
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
|
|
||||||
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
|
|
||||||
// naming the installation they were written in. This is the check.
|
|
||||||
//
|
|
||||||
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
|
|
||||||
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
|
|
||||||
// people to ignore the report. What is judged is every other string value: a name under a public
|
|
||||||
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
|
|
||||||
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
|
|
||||||
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
|
|
||||||
// its host is the runtime's, true on every machine that runs it.
|
|
||||||
//
|
|
||||||
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
|
|
||||||
// server's config names the federation's public directory; an application built outside the mesh
|
|
||||||
// is pulled from the registry that built it, until the mesh builds it. The resource carries
|
|
||||||
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
|
|
||||||
// per name — so a reader sees which names a definition means to carry and why, a name the map does
|
|
||||||
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
|
|
||||||
|
|
||||||
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
|
|
||||||
// each name mapped to its reason. The host never sees it.
|
|
||||||
const NamesOnPurpose = "names-on-purpose"
|
|
||||||
|
|
||||||
// prose is every key whose value the mesh never reads.
|
|
||||||
var prose = map[string]bool{"why": true, "description": true}
|
|
||||||
|
|
||||||
// Registries the world runs, which an image may name because an image reference must say where it
|
|
||||||
// is pulled from. Anything else in an image reference is a registry of some installation.
|
|
||||||
var worldsRegistries = map[string]bool{
|
|
||||||
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
|
|
||||||
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
|
|
||||||
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
|
|
||||||
"codeberg.org": true, "cgr.dev": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Services the world runs that a definition may name as a policy default, the way it may name a
|
|
||||||
// public resolver: the public certificate authorities' ACME directories. Anything else a served
|
|
||||||
// fact or a file names is somebody's installation.
|
|
||||||
var worldsServices = map[string]bool{
|
|
||||||
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
|
|
||||||
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
|
|
||||||
"acme.zerossl.com": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
|
|
||||||
var worldsResolvers = map[string]bool{
|
|
||||||
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
|
|
||||||
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
|
|
||||||
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
|
|
||||||
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
|
|
||||||
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
|
|
||||||
var hostname = regexp.MustCompile(
|
|
||||||
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
|
|
||||||
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
|
|
||||||
`internal|example|tld|test|invalid)`)
|
|
||||||
|
|
||||||
// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version).
|
|
||||||
var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`)
|
|
||||||
|
|
||||||
// address is a dotted quad.
|
|
||||||
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
|
||||||
|
|
||||||
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
|
|
||||||
func isName(b byte) bool {
|
|
||||||
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
|
||||||
}
|
|
||||||
|
|
||||||
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
|
|
||||||
func standalone(re *regexp.Regexp, value string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, span := range re.FindAllStringIndex(value, -1) {
|
|
||||||
if span[0] > 0 && isName(value[span[0]-1]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if span[1] < len(value) && isName(value[span[1]]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, value[span[0]:span[1]])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallationProblems is every value of a definition that names an installation, in the
|
|
||||||
// definition's own words: where it is, and what it names.
|
|
||||||
func InstallationProblems(m Manifest) []string {
|
|
||||||
raw, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
|
||||||
}
|
|
||||||
var tree any
|
|
||||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
// The module's own name is a value too: a module named after the domain it serves is a
|
|
||||||
// definition that can only be installed there (issue 134).
|
|
||||||
for _, name := range namesIn(m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
|
|
||||||
}
|
|
||||||
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
|
|
||||||
for _, name := range namesIn(value) {
|
|
||||||
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
|
|
||||||
}
|
|
||||||
for _, ip := range addressesIn(value) {
|
|
||||||
// An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name
|
|
||||||
// in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's
|
|
||||||
// names-on-purpose like any name a definition means (novox/hq ADR 0316).
|
|
||||||
if meant[ip] || upstreamVersion.MatchString(at) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
sort.Strings(problems)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// walk visits every string in the tree with its path, the names the enclosing resource means to
|
|
||||||
// name (with a reason), and whether it is an image reference.
|
|
||||||
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
|
|
||||||
switch v := node.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
if declared, has := v[NamesOnPurpose].(map[string]any); has {
|
|
||||||
widened := map[string]bool{}
|
|
||||||
for name := range meant {
|
|
||||||
widened[name] = true
|
|
||||||
}
|
|
||||||
for name, reason := range declared {
|
|
||||||
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
|
|
||||||
widened[strings.ToLower(name)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meant = widened
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(v))
|
|
||||||
for k := range v {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
// Prose is a string a person reads. A key that is called `why` or `description` and holds
|
|
||||||
// anything else — a setting of that name, whose default the mesh writes — is walked like any
|
|
||||||
// other (novox/hq ADR 0262).
|
|
||||||
if _, isString := v[k].(string); (prose[k] && isString) || k == NamesOnPurpose || (at == "" && k == "module") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
child := at + "." + k
|
|
||||||
if at == "" {
|
|
||||||
child = k
|
|
||||||
}
|
|
||||||
if s, isString := v[k].(string); isString {
|
|
||||||
visit(child, s, meant, k == "image")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
walk(v[k], child, meant, visit)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for i, item := range v {
|
|
||||||
child := fmt.Sprintf("%s[%d]", at, i)
|
|
||||||
if s, isString := item.(string); isString {
|
|
||||||
visit(child, s, meant, false)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
walk(item, child, meant, visit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesIn is every hostname in a value that could belong to an installation.
|
|
||||||
func namesIn(value string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, found := range standalone(hostname, value) {
|
|
||||||
name := strings.ToLower(found)
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(name, ".docker.internal"):
|
|
||||||
// The container runtime's alias for its own host: every machine running it has one.
|
|
||||||
case worldsServices[name]:
|
|
||||||
// A public authority named as a policy default, true of any mesh that wants it.
|
|
||||||
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
|
|
||||||
name == "example.com", name == "example.net", name == "example.org",
|
|
||||||
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
|
|
||||||
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
|
|
||||||
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
|
|
||||||
// Documentation names, which is what a definition's own example should use.
|
|
||||||
default:
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
|
|
||||||
// unspecified address, a documentation range, or a resolver the world runs.
|
|
||||||
func addressesIn(value string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, found := range standalone(address, value) {
|
|
||||||
ip := net.ParseIP(found)
|
|
||||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
|
||||||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, found)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentation(ip net.IP) bool {
|
|
||||||
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
|
|
||||||
_, block, _ := net.ParseCIDR(cidr)
|
|
||||||
if block.Contains(ip) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A node's fail2ban jails, composed from the modules it runs (novox/hq to-be 31).
|
|
||||||
//
|
|
||||||
// **The same shape as the firewall.** Every module's `listens` become the node's rule set; every
|
|
||||||
// module's `jails` become the node's fail2ban config. A module that runs an authenticating service
|
|
||||||
// declares what a break-in on it looks like and how to ban it, naming no node and no path (ADR
|
|
||||||
// 0112); the intrusion-prevention holder — the one module with `jailing` — gathers them and writes
|
|
||||||
// them where it owns. A node not running a module has none of its jails.
|
|
||||||
|
|
||||||
// jailsInto composes every jail declared by the modules on a node into the files the holder writes:
|
|
||||||
// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter
|
|
||||||
// file per jail (its failregex, which fail2ban references by the jail's name).
|
|
||||||
//
|
|
||||||
// Owned by the holder, because the directory is: two modules writing into one fail2ban is the
|
|
||||||
// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file
|
|
||||||
// is written empty rather than absent, so removing the last jail is an ordinary change the service
|
|
||||||
// restarts on rather than a file that vanishes.
|
|
||||||
func jailsInto(modules []Manifest, j *Jailing) []map[string]any {
|
|
||||||
type declared struct {
|
|
||||||
module string
|
|
||||||
jail Jail
|
|
||||||
}
|
|
||||||
var jails []declared
|
|
||||||
for _, m := range modules {
|
|
||||||
for _, jail := range m.Jails {
|
|
||||||
jails = append(jails, declared{m.Module, jail})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A stable order the host applies as given (ADR 0005), and so the same set composes byte for
|
|
||||||
// byte every time rather than differing by map iteration.
|
|
||||||
sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name })
|
|
||||||
|
|
||||||
var composed strings.Builder
|
|
||||||
composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n")
|
|
||||||
composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n")
|
|
||||||
|
|
||||||
out := make([]map[string]any, 0, len(jails)+1)
|
|
||||||
for _, d := range jails {
|
|
||||||
// **The filter's digest rides in the jail file.** fail2ban is restarted when this file
|
|
||||||
// changes, and the filter is a file of its own: a module that changed only what a failure
|
|
||||||
// looks like rewrote the filter on disk and left the running jail on the old pattern, with
|
|
||||||
// nothing said (novox/hq issue 191's rollout found it on gitea's sshd). Naming the filter's
|
|
||||||
// digest here makes a changed pattern a changed jail file, so the restart the service
|
|
||||||
// already takes on it covers the filter too.
|
|
||||||
sum := sha256.Sum256([]byte(d.jail.Failregex))
|
|
||||||
fmt.Fprintf(&composed, "\n# from %s, filter %s\n[%s]\nenabled = true\nfilter = %s\n%s\n",
|
|
||||||
d.module, hex.EncodeToString(sum[:])[:12], d.jail.Name, d.jail.Name,
|
|
||||||
strings.TrimRight(d.jail.Jail, "\n"))
|
|
||||||
// The filter is a file of its own, named as the jail's filter= references it.
|
|
||||||
out = append(out, map[string]any{
|
|
||||||
"id": "filter-" + d.jail.Name,
|
|
||||||
"type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf",
|
|
||||||
"mode": "0644",
|
|
||||||
"content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" +
|
|
||||||
"[Definition]\nfailregex = " + d.jail.Failregex + "\n",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// The one jail file, first, with the fixed id the fail2ban service names in its restart-on.
|
|
||||||
return append([]map[string]any{{
|
|
||||||
"id": ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644",
|
|
||||||
"content": composed.String(),
|
|
||||||
}}, out...)
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/netip"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AttachedNetwork is one network a machine said it is directly attached to: the link it is on and the
|
|
||||||
// network as address and prefix (novox/hq ADR 0340 §7), as the node-engine reports it among its facts.
|
|
||||||
type AttachedNetwork struct {
|
|
||||||
Link string `json:"link"`
|
|
||||||
Network string `json:"network"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalNetwork is one attached network judged: whether it counts as this machine's local network for a
|
|
||||||
// port reached from `local-network`, and why — said for one that counts as well as for one that does
|
|
||||||
// not, so `node` can show which counted and why the others did not (ADR 0340 §8).
|
|
||||||
type LocalNetwork struct {
|
|
||||||
Link string
|
|
||||||
Network string
|
|
||||||
Counts bool
|
|
||||||
Why string
|
|
||||||
}
|
|
||||||
|
|
||||||
// privateRanges are the only ranges a local network may lie in (ADR 0340 §2): RFC 1918 for IPv4 and the
|
|
||||||
// unique local addresses for IPv6. A network counts only when it lies wholly inside one of them.
|
|
||||||
var privateRanges = []struct {
|
|
||||||
prefix netip.Prefix
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{netip.MustParsePrefix("10.0.0.0/8"), "a private range (RFC 1918, 10.0.0.0/8)"},
|
|
||||||
{netip.MustParsePrefix("172.16.0.0/12"), "a private range (RFC 1918, 172.16.0.0/12)"},
|
|
||||||
{netip.MustParsePrefix("192.168.0.0/16"), "a private range (RFC 1918, 192.168.0.0/16)"},
|
|
||||||
{netip.MustParsePrefix("fc00::/7"), "the IPv6 unique local range (fc00::/7)"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// JudgeLocalNetworks says, for each network a machine reported, whether it counts as its local network.
|
|
||||||
//
|
|
||||||
// **A public subnet never counts** (ADR 0340 §2): a network counts only when it lies wholly in a private
|
|
||||||
// range, so `local-network` can never open a port to the internet — a machine sitting on a public /24 has
|
|
||||||
// that /24 judged and refused, never rendered. A link-local network does not count either. And only a
|
|
||||||
// link the machine reported as facing outside it counts (§3): the private network's own link and a
|
|
||||||
// container bridge are this machine's own, not its local network, whatever their addresses.
|
|
||||||
//
|
|
||||||
// Sorted by link and then network, one entry per (link, network), so what `node` shows and what the
|
|
||||||
// filter is rendered from do not depend on the order a machine happened to say them in.
|
|
||||||
func JudgeLocalNetworks(attached []AttachedNetwork, outward []string, tunnel string) []LocalNetwork {
|
|
||||||
seen := map[[2]string]bool{}
|
|
||||||
var out []LocalNetwork
|
|
||||||
for _, a := range attached {
|
|
||||||
key := [2]string{a.Link, a.Network}
|
|
||||||
if seen[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
out = append(out, judgeOne(a, outward, tunnel))
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Link != out[j].Link {
|
|
||||||
return out[i].Link < out[j].Link
|
|
||||||
}
|
|
||||||
return out[i].Network < out[j].Network
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func judgeOne(a AttachedNetwork, outward []string, tunnel string) LocalNetwork {
|
|
||||||
j := LocalNetwork{Link: a.Link, Network: a.Network}
|
|
||||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(a.Network))
|
|
||||||
if err != nil {
|
|
||||||
j.Why = "not a network the mesh can read (" + err.Error() + ")"
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
prefix = prefix.Masked()
|
|
||||||
j.Network = prefix.String()
|
|
||||||
switch {
|
|
||||||
case tunnel != "" && a.Link == tunnel:
|
|
||||||
j.Why = "the private network's own link, not the local network"
|
|
||||||
return j
|
|
||||||
case !slices.Contains(outward, a.Link):
|
|
||||||
j.Why = fmt.Sprintf("%s does not face outside this machine (a bridge or a link of its own)", a.Link)
|
|
||||||
return j
|
|
||||||
case prefix.Addr().IsLoopback():
|
|
||||||
j.Why = "loopback"
|
|
||||||
return j
|
|
||||||
case prefix.Addr().IsLinkLocalUnicast():
|
|
||||||
j.Why = "link-local, never counted"
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
for _, r := range privateRanges {
|
|
||||||
if r.prefix.Contains(prefix.Addr()) && prefix.Bits() >= r.prefix.Bits() {
|
|
||||||
j.Counts = true
|
|
||||||
j.Why = "in " + r.name
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if prefix.Addr().IsPrivate() {
|
|
||||||
// Overlaps a private range without lying wholly inside it (a /7 around 10.0.0.0/8, say).
|
|
||||||
j.Why = "not wholly in a private range, so never counted"
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
j.Why = "public, never counted: local-network may not open a port to the internet"
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountedLocalNetworks is the networks that count, as the filter renders them: sorted, without repeats.
|
|
||||||
func CountedLocalNetworks(judged []LocalNetwork) []string {
|
|
||||||
var out []string
|
|
||||||
for _, j := range judged {
|
|
||||||
if j.Counts && !slices.Contains(out, j.Network) {
|
|
||||||
out = append(out, j.Network)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalNetworkPort is one port the filter admits from `local-network`, and what that resolved to on this
|
|
||||||
// machine (ADR 0340 §8): what `plan` shows for it.
|
|
||||||
type LocalNetworkPort struct {
|
|
||||||
Port int
|
|
||||||
Protocol string
|
|
||||||
Because []string
|
|
||||||
// Networks is the local networks it is admitted from; empty when the machine reported none (§6).
|
|
||||||
Networks []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to.
|
|
||||||
func LocalNetworkPorts(rules []Rule, networks []string) []LocalNetworkPort {
|
|
||||||
var out []LocalNetworkPort
|
|
||||||
for _, rule := range rules {
|
|
||||||
if rule.From != FromLocalNetwork {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol,
|
|
||||||
Because: rule.Because, Networks: networks})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Said is how `plan` says one such port: the networks, or that there are none and the port is admitted
|
|
||||||
// from the private network only.
|
|
||||||
func (p LocalNetworkPort) Said() string {
|
|
||||||
by := strings.Join(p.Because, ", ")
|
|
||||||
if len(p.Networks) == 0 {
|
|
||||||
return fmt.Sprintf("%s/%d (%s) from the local network: none — this machine reported no private "+
|
|
||||||
"network on a link facing outside, so it is admitted from the private network only", p.Protocol, p.Port, by)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s/%d (%s) from the local network: %s, and the private network", p.Protocol, p.Port, by,
|
|
||||||
strings.Join(p.Networks, ", "))
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a module may call its logs (novox/hq ADR 0297).
|
|
||||||
//
|
|
||||||
// A log is a module's record of operations: entries appended under a key, each kept as long as the
|
|
||||||
// log is, in the order they came. A module names each log it owns **locally** — `changes`, never a
|
|
||||||
// stream or a subject (ADR 0201 §4) — and the mesh derives the stream from the module and the local
|
|
||||||
// name, as it derives a bucket. So the rule for a log's name is a state name's: one plain token, and
|
|
||||||
// a module that keeps a log has a name that is one plain token too.
|
|
||||||
|
|
||||||
// The mesh's caps on a log, in MiB: what a module may ask, and what it gets when it asks nothing.
|
|
||||||
const (
|
|
||||||
LogLeastMiB = 1
|
|
||||||
LogMostMiB = 8192
|
|
||||||
LogDefaultMiB = 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// LogDeclaration is one log a module owns: its local name, and how large it may grow.
|
|
||||||
type LogDeclaration struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// MaxMiB is the log's cap in MiB; zero is LogDefaultMiB. When full, the log refuses new entries
|
|
||||||
// and never drops old ones.
|
|
||||||
MaxMiB int `json:"max-mib,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap is the log's cap in MiB, the default where none is said.
|
|
||||||
func (l LogDeclaration) Cap() int {
|
|
||||||
if l.MaxMiB == 0 {
|
|
||||||
return LogDefaultMiB
|
|
||||||
}
|
|
||||||
return l.MaxMiB
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads a log as its bare name, or as {name, max-mib}.
|
|
||||||
func (l *LogDeclaration) UnmarshalJSON(raw []byte) error {
|
|
||||||
trimmed := bytes.TrimSpace(raw)
|
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
|
||||||
return json.Unmarshal(trimmed, &l.Name)
|
|
||||||
}
|
|
||||||
var full struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
MaxMiB *int `json:"max-mib"`
|
|
||||||
}
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&full); err != nil {
|
|
||||||
return fmt.Errorf("a log is either a name or {name, max-mib}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
l.Name = full.Name
|
|
||||||
l.MaxMiB = 0
|
|
||||||
if full.MaxMiB != nil {
|
|
||||||
// Said, and said as nothing: refused rather than read as the default, which it did not say.
|
|
||||||
if *full.MaxMiB == 0 {
|
|
||||||
return fmt.Errorf("log %q: max-mib is between %d and %d, not 0", full.Name, LogLeastMiB, LogMostMiB)
|
|
||||||
}
|
|
||||||
l.MaxMiB = *full.MaxMiB
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
|
||||||
func (l LogDeclaration) MarshalJSON() ([]byte, error) {
|
|
||||||
if l.MaxMiB == 0 {
|
|
||||||
return json.Marshal(l.Name)
|
|
||||||
}
|
|
||||||
type plain LogDeclaration
|
|
||||||
return json.Marshal(plain(l))
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogProblems is what is wrong with a manifest's logs.
|
|
||||||
//
|
|
||||||
// Refused at registration, for a bucket's reason: a stream name the bus cannot hold, or a cap the
|
|
||||||
// mesh would not grant, is a module that installs, starts, and is refused on its first append.
|
|
||||||
func LogProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
if len(m.Logs) > 0 && !stateName.MatchString(m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps a log, and a module's name is part of its logs' names, which take one plain "+
|
|
||||||
"name — no dot (novox/hq ADR 0297)", m.Module))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, l := range m.Logs {
|
|
||||||
switch {
|
|
||||||
case !stateName.MatchString(l.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps log %q: a log is named locally — lower-case letters, digits and hyphens, "+
|
|
||||||
"no dot and no underscore; the mesh derives the stream (novox/hq ADR 0297)", m.Module, l.Name))
|
|
||||||
case seen[l.Name]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s keeps log %q twice", m.Module, l.Name))
|
|
||||||
}
|
|
||||||
seen[l.Name] = true
|
|
||||||
if l.MaxMiB != 0 && (l.MaxMiB < LogLeastMiB || l.MaxMiB > LogMostMiB) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s caps log %q at %d MiB; a log holds between %d and %d MiB (novox/hq ADR 0297)",
|
|
||||||
m.Module, l.Name, l.MaxMiB, LogLeastMiB, LogMostMiB))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a module may say about the machine it is running on.
|
|
||||||
//
|
|
||||||
// **A module cannot know where it will be assigned, and sometimes it must say so anyway.** Every
|
|
||||||
// other name in a declaration is either the module's own — which it wrote — or something it
|
|
||||||
// requires, which arrives as a binding. The machine underneath is neither: it is chosen when the
|
|
||||||
// module is assigned, long after the manifest was written, and until now nothing carried it into a
|
|
||||||
// file.
|
|
||||||
//
|
|
||||||
// The case that found this is a certificate authority inside the mesh (novox/hq ADR 0066). A proxy
|
|
||||||
// reaches it at the address the mesh handed over, `<machine>.internal` — so the authority's own
|
|
||||||
// certificate has to be issued for that name, or the first thing that happens is the proxy refusing
|
|
||||||
// to talk to it. The authority is the one thing that cannot be told its name by a binding: it
|
|
||||||
// provides, it does not require. Written as a literal it would be a manifest carrying one
|
|
||||||
// deployment's machine name, which is the shape [ADR 0066] exists to remove.
|
|
||||||
//
|
|
||||||
// Three facts, all the mesh's own vocabulary — the same `node` and `at` a contribution already
|
|
||||||
// carries, and the address behind `at`, for software that takes an address and not a name. The
|
|
||||||
// case that found the third is a resolver pointing the container runtime at itself: the runtime's
|
|
||||||
// list of resolvers is addresses, because a name there would have to be resolved by the resolver
|
|
||||||
// it names. Nothing about what a machine is *for*: that would be the mesh learning what a module
|
|
||||||
// means, which it does not do.
|
|
||||||
|
|
||||||
// ofMachine is where a module says a fact about the machine underneath it belongs:
|
|
||||||
// ${machine:<key>}.
|
|
||||||
var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`)
|
|
||||||
|
|
||||||
// machineUsed are the keys a file's content asks for, first appearance first.
|
|
||||||
func machineUsed(content string) []string {
|
|
||||||
var used []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range ofMachine.FindAllStringSubmatch(content, -1) {
|
|
||||||
if key := m[1]; !seen[key] {
|
|
||||||
seen[key] = true
|
|
||||||
used = append(used, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return used
|
|
||||||
}
|
|
||||||
|
|
||||||
// machineFacts is what a module may name about the machine it was assigned to.
|
|
||||||
//
|
|
||||||
// `at` is absent rather than empty when the machine is not on the private network. A module asking
|
|
||||||
// to be reached at an address that does not exist is a misconfiguration, and it is said here —
|
|
||||||
// where the module and the machine are both named — rather than discovered later as a certificate
|
|
||||||
// nobody can verify.
|
|
||||||
//
|
|
||||||
// `address` is what `at` resolves to, read from the names the control plane composed — the same map
|
|
||||||
// the hosts file and the resolver's wildcards are written from, so a file naming the machine's
|
|
||||||
// address and the file every other machine reaches it by cannot disagree. Absent, like `at`, when
|
|
||||||
// the machine is off the network or the mesh has not placed it.
|
|
||||||
func machineFacts(r Resolution, names map[string]string, meshRange string) map[string]string {
|
|
||||||
out := map[string]string{"name": r.Node}
|
|
||||||
if r.At != "" {
|
|
||||||
out["at"] = r.At
|
|
||||||
if address := names[r.At]; address != "" {
|
|
||||||
out["address"] = address
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The private network's whole range — a mesh-wide fact, not this machine's, but named here
|
|
||||||
// because a module cannot know it and sometimes must (an intrusion filter that must never ban a
|
|
||||||
// tunnel peer). Absent when the mesh has no range to give.
|
|
||||||
if meshRange != "" {
|
|
||||||
out["mesh-range"] = meshRange
|
|
||||||
}
|
|
||||||
// The operator's login on this machine and where its home is (novox/hq to-be 29), so a module
|
|
||||||
// that writes operator config names the account and its home rather than a value it cannot know.
|
|
||||||
// Absent when no operator account is known — a headless box a person never logs into.
|
|
||||||
if r.Account != "" {
|
|
||||||
out["account"] = r.Account
|
|
||||||
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
|
|
||||||
}
|
|
||||||
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
|
|
||||||
// account where the node names one; the operator account otherwise, so a module writing the agent's
|
|
||||||
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
|
|
||||||
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
|
|
||||||
// as the operator it is empty, asserting nothing — the operator's account may become root there.
|
|
||||||
if agent, home := r.agentAccount(); agent != "" {
|
|
||||||
out["agent-account"] = agent
|
|
||||||
out["agent-home"] = home
|
|
||||||
out["agent-root"] = ""
|
|
||||||
if r.AgentAccount != "" {
|
|
||||||
out["agent-root"] = RootNever
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RootNever is what a user resource's `root` says of an account that must never become root without a
|
|
||||||
// person (novox/hq ADR 0266); the node-engine judges it.
|
|
||||||
const RootNever = "never"
|
|
||||||
|
|
||||||
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
|
|
||||||
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
|
|
||||||
func (r Resolution) agentAccount() (string, string) {
|
|
||||||
if r.AgentAccount != "" {
|
|
||||||
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
|
|
||||||
}
|
|
||||||
if r.Account != "" {
|
|
||||||
return r.Account, accountHomeOf(r.Account, r.AccountHome)
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
|
|
||||||
// agent account is never root.
|
|
||||||
func agentHomeOf(account, home string) string {
|
|
||||||
if home != "" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
return "/home/" + account
|
|
||||||
}
|
|
||||||
|
|
||||||
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
|
|
||||||
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
|
|
||||||
// cannot disagree about it.
|
|
||||||
func accountHomeOf(account, home string) string {
|
|
||||||
if home != "" {
|
|
||||||
return home
|
|
||||||
}
|
|
||||||
if account == "root" {
|
|
||||||
return "/root"
|
|
||||||
}
|
|
||||||
return "/home/" + account
|
|
||||||
}
|
|
||||||
|
|
||||||
// machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the
|
|
||||||
// machine the module was assigned to.
|
|
||||||
//
|
|
||||||
// A key the mesh does not hold is refused, for the same reason a binding's is: left alone, the
|
|
||||||
// literal would be written into a configuration file and read as a value.
|
|
||||||
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
|
||||||
// Content, and now the path and owner too: a module that writes into a person's home names it
|
|
||||||
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
|
||||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
|
||||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
|
||||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
|
||||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
|
|
||||||
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
|
|
||||||
// "never" only where that account is the agents' own (novox/hq ADR 0266), and its `home`, so an account
|
|
||||||
// the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a
|
|
||||||
// user's home only when it creates the account, so an existing one is never moved.
|
|
||||||
for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} {
|
|
||||||
s, ok := resource[field].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, key := range machineUsed(s) {
|
|
||||||
value, has := facts[key]
|
|
||||||
if !has {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s has a %s that says ${machine:%s}, and this machine says %s",
|
|
||||||
module, field, key, orNothing(namesOfFacts(facts)))
|
|
||||||
}
|
|
||||||
s = strings.ReplaceAll(s, fmt.Sprintf("${machine:%s}", key), value)
|
|
||||||
resource[field] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfFacts(facts map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(facts))
|
|
||||||
for k := range facts {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
// Provision names that say a role where the consumer is coupled to an implementation.
|
|
||||||
//
|
|
||||||
// **A consumer's code is written against PostgreSQL or against SQL Server, never against a
|
|
||||||
// database** (novox/hq ADR 0027). A requirement naming the role matches any engine, resolves as
|
|
||||||
// satisfied, deploys, and fails on the first query — with nothing connecting the error back to a
|
|
||||||
// match made elsewhere by something that thought it had done its job.
|
|
||||||
//
|
|
||||||
// Refused rather than documented, because the previous naming *was* the documented convention.
|
|
||||||
// A rule states how it is checked (novox/hq 00-META/how-we-build.md §5).
|
|
||||||
var engineGeneric = map[string]string{
|
|
||||||
"database": "postgres-database, mssql-database, mariadb-database",
|
|
||||||
"db": "postgres-database, mssql-database, mariadb-database",
|
|
||||||
"sql": "postgres-database, mssql-database, mariadb-database",
|
|
||||||
"sql-database": "postgres-database, mssql-database, mariadb-database",
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import "path"
|
|
||||||
|
|
||||||
// The machine's own resolver (novox/hq ADR 0247).
|
|
||||||
//
|
|
||||||
// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing
|
|
||||||
// else, and the module holding its uplink writes that file (ADR 0223). A machine with a VPN client that
|
|
||||||
// pushes its own resolvers for its own domains needs a third answer: those domains to the VPN's servers,
|
|
||||||
// over the VPN's link, and every other name to the mesh's resolvers as before. One file cannot list both
|
|
||||||
// sets of servers — musl takes the first reply, glibc the first server's "no such name" — so the domains
|
|
||||||
// are routed by a resolver on the machine itself: the `node-resolver` seat's holder.
|
|
||||||
//
|
|
||||||
// **Where it is held, it owns the resolver file.** The file then names the machine's own resolver, which
|
|
||||||
// routes; the uplink's holder steps back from writing it on that machine, by the rule below and not by
|
|
||||||
// two modules writing one path. There are two uplink holders (NetworkManager's and systemd-networkd's),
|
|
||||||
// and the resolver is its own module so it is written once, not once in each.
|
|
||||||
//
|
|
||||||
// **It knows nothing of any VPN.** Its verbs route a set of domains to a set of servers over one link,
|
|
||||||
// list what is routed and remove a route. A module wrapping a VPN client that writes /etc/resolv.conf
|
|
||||||
// itself carries its own adapter and calls those verbs; a VPN that tells systemd-resolved its link's DNS
|
|
||||||
// itself needs none.
|
|
||||||
|
|
||||||
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
|
|
||||||
const ResolverSeat = "node-resolver"
|
|
||||||
|
|
||||||
// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where
|
|
||||||
// one is held.
|
|
||||||
const ResolverFile = "/etc/resolv.conf"
|
|
||||||
|
|
||||||
// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed
|
|
||||||
// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to
|
|
||||||
// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the
|
|
||||||
// operator's way to read and correct the same.
|
|
||||||
func resolverVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " +
|
|
||||||
"which answer every name not routed elsewhere, and each link given servers of its own with the " +
|
|
||||||
"domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " +
|
|
||||||
"wrote it as far as the file says, whether a module took it, and when the resolver's own file was " +
|
|
||||||
"put back.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}},
|
|
||||||
{Name: "route", Description: "Send these domains, and every name under them, to these servers over " +
|
|
||||||
"this link — and only them: the link is never the machine's default route for names, and the " +
|
|
||||||
"mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " +
|
|
||||||
"takes its route with it.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"link": "the network link the servers are reached over, by name (a VPN's tunnel interface)",
|
|
||||||
"domains": "the domains to route there, separated by spaces or commas",
|
|
||||||
"servers": "the servers' addresses, separated by spaces or commas",
|
|
||||||
}, []string{"link", "domains", "servers"}),
|
|
||||||
Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}},
|
|
||||||
{Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " +
|
|
||||||
"resolvers. Nothing changes when the link has none.",
|
|
||||||
Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}),
|
|
||||||
Replaces: []string{"resolvectl revert"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// holdsSeat says whether a module claims a seat, by its current name.
|
|
||||||
func holdsSeat(m Manifest, seat string) bool {
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if canonicalSeat(c.Name) == seat {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// stepsBack is the module as it composes among the modules of one machine (novox/hq ADR 0287,
|
|
||||||
// ADR 0247):
|
|
||||||
// **where it holds a seat that names a path stepping back for another seat, and that other seat's holder
|
|
||||||
// is on the machine and declares the path, its own rendering of the path is left out**, and nothing else
|
|
||||||
// of it changes. Only a file the mesh renders for it steps back, as before: a resource on the path is
|
|
||||||
// composed as declared (the declaration copies a module's resources whole), so an uplink holder declaring
|
|
||||||
// the resolver file as a resource beside the resolver's holder is two owners of one path, and refused. The stepping back is the seat's definition — node-uplink stepping back from the resolver
|
|
||||||
// file for node-resolver — so any other module declaring the path beside the owner's is still two owners
|
|
||||||
// of one path, and is refused as before.
|
|
||||||
func stepsBack(m Manifest, modules []Manifest) Manifest {
|
|
||||||
drop := map[string]bool{}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
seat, known := SeatNamed(c.Name)
|
|
||||||
if !known {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, p := range seat.Paths {
|
|
||||||
if p.StepsBackFor == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, o := range modules {
|
|
||||||
if o.Module != m.Module && holdsSeat(o, p.StepsBackFor) && declaresPath(o, p.Path) {
|
|
||||||
drop[p.Path] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(drop) == 0 {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
facts := make(map[string]RosterFile, len(m.Facts))
|
|
||||||
for name, f := range m.Facts {
|
|
||||||
if !f.Home && drop[path.Clean(f.Path)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
facts[name] = f
|
|
||||||
}
|
|
||||||
m.Facts = facts
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// steppedBack is every module of one machine as it composes there (stepsBack, each).
|
|
||||||
func steppedBack(modules []Manifest) []Manifest {
|
|
||||||
out := make([]Manifest, len(modules))
|
|
||||||
for i, m := range modules {
|
|
||||||
out[i] = stepsBack(m, modules)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A requirement a module can run without (novox/hq ADR 0330, issue 166).
|
|
||||||
//
|
|
||||||
// **Hard stays the default.** A requirement written as a bare name is hard, exactly as before: it
|
|
||||||
// resolves, or the assignment is refused, and a single catalogue candidate is installed for it. One
|
|
||||||
// written `{"name": "influxdb-api", "optional": true}` resolves the same way when a provider is
|
|
||||||
// already assigned in the mesh or reachable from the consumer (§2), and is otherwise **absent**: not
|
|
||||||
// refused, and nothing is installed for it, on this machine or elsewhere (§3).
|
|
||||||
//
|
|
||||||
// **An absent requirement is never rendered as an empty value** (§4–§6). Its binding, its pair
|
|
||||||
// credential and its grant are not composed, so a file that names one of them is delivered only while
|
|
||||||
// the requirement is bound (`only-when-bound`), and a `${bound:…}` placeholder of it outside such a
|
|
||||||
// file names the value it takes while absent: `${bound:<provision>:<key>|default:<value>}`. The
|
|
||||||
// module check refuses anything else, so the gap is found when the manifest is written, not on a
|
|
||||||
// machine where it reads as a blank.
|
|
||||||
|
|
||||||
// OnlyWhenBound is a resource's field naming the optional requirement it is delivered only while
|
|
||||||
// bound (ADR 0330 §5). Said in the catalogue and dropped before the declaration is sent: the
|
|
||||||
// node-engine parses strictly and knows no such field.
|
|
||||||
const OnlyWhenBound = "only-when-bound"
|
|
||||||
|
|
||||||
// requirementForm is one entry of `requires` in its long form.
|
|
||||||
type requirementForm struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Optional bool `json:"optional,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRequires reads `requires` in both of its forms — a bare name, or {name, optional} — into the
|
|
||||||
// names and which of them are optional.
|
|
||||||
func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
|
|
||||||
var entries []json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
optional := map[string]bool{}
|
|
||||||
for i, e := range entries {
|
|
||||||
var plain string
|
|
||||||
if err := json.Unmarshal(e, &plain); err == nil {
|
|
||||||
names = append(names, plain)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var full requirementForm
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(e))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&full); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(full.Name) == "" {
|
|
||||||
return nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
|
|
||||||
}
|
|
||||||
names = append(names, full.Name)
|
|
||||||
if full.Optional {
|
|
||||||
optional[full.Name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(optional) == 0 {
|
|
||||||
optional = nil
|
|
||||||
}
|
|
||||||
return names, optional, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionalRequiresKey is where a stored manifest keeps which of its requirements are optional: beside
|
|
||||||
// `requires`, which stays a plain list of names (mesh-controller #281's review). A controller older than
|
|
||||||
// optional requirements reads `requires` as names; the long form inside it failed that read, and with it
|
|
||||||
// the whole catalogue, so a rollback below this controller would have composed no machine. An older
|
|
||||||
// controller sets this unknown key aside instead and leaves only the module that carries it out. A
|
|
||||||
// manifest may be written either way; it is stored this way.
|
|
||||||
const OptionalRequiresKey = "optional-requires"
|
|
||||||
|
|
||||||
// readOptionalRequires reads the stored form's list of optional requirements.
|
|
||||||
func readOptionalRequires(raw json.RawMessage) (map[string]bool, error) {
|
|
||||||
var names []string
|
|
||||||
if err := json.Unmarshal(raw, &names); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: a list of the names in `requires` that are optional: %w", OptionalRequiresKey, err)
|
|
||||||
}
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, n := range names {
|
|
||||||
out[n] = true
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionalRequiresList is the stored form's list: the optional requirements, sorted.
|
|
||||||
func optionalRequiresList(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
for name, optional := range m.OptionalRequires {
|
|
||||||
if optional {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsOptional is whether this module requires the name optionally (ADR 0330 §1). A name it contributes
|
|
||||||
// to without requiring, or requires as a bare name, is hard.
|
|
||||||
func (m Manifest) IsOptional(requirement string) bool { return m.OptionalRequires[requirement] }
|
|
||||||
|
|
||||||
// wantsHard is whether this module wants the name as a hard requirement: required without
|
|
||||||
// `optional`, or contributed to.
|
|
||||||
func (m Manifest) wantsHard(name string) bool {
|
|
||||||
for _, w := range m.Wants() {
|
|
||||||
if w == name {
|
|
||||||
return !m.IsOptional(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Absent is one optional requirement of one consumer that nothing assigned answers (ADR 0330 §3):
|
|
||||||
// left unbound rather than refused, and listed by `plan` and `node` (§7).
|
|
||||||
type Absent struct {
|
|
||||||
// Consumer is the module that requires it optionally.
|
|
||||||
Consumer string `json:"consumer"`
|
|
||||||
// Requirement is what it requires.
|
|
||||||
Requirement string `json:"requirement"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AbsentFor is the optional requirements of one module this resolution leaves unbound.
|
|
||||||
func (r Resolution) AbsentFor(module string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, a := range r.Absent {
|
|
||||||
if a.Consumer == module {
|
|
||||||
out[a.Requirement] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// absentOf is, for each requirement left absent, every module of the set that requires it
|
|
||||||
// optionally, sorted by consumer then requirement.
|
|
||||||
func absentOf(names map[string]bool, order []string, catalogue map[string]Manifest) []Absent {
|
|
||||||
var out []Absent
|
|
||||||
for _, module := range order {
|
|
||||||
m := catalogue[module]
|
|
||||||
for _, r := range m.Requires {
|
|
||||||
if names[r] && m.IsOptional(r) {
|
|
||||||
out = append(out, Absent{Consumer: module, Requirement: r})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Consumer != out[j].Consumer {
|
|
||||||
return out[i].Consumer < out[j].Consumer
|
|
||||||
}
|
|
||||||
return out[i].Requirement < out[j].Requirement
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// withoutAbsent drops the resources delivered only while a requirement is bound whose requirement is
|
|
||||||
// absent here (ADR 0330 §5), and the field itself from every resource that keeps it.
|
|
||||||
func withoutAbsent(resources []map[string]any, absent map[string]bool) []map[string]any {
|
|
||||||
out := make([]map[string]any, 0, len(resources))
|
|
||||||
for _, r := range resources {
|
|
||||||
when, has := r[OnlyWhenBound]
|
|
||||||
if !has {
|
|
||||||
out = append(out, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if absent[fmt.Sprint(when)] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept := make(map[string]any, len(r))
|
|
||||||
for k, v := range r {
|
|
||||||
if k != OnlyWhenBound {
|
|
||||||
kept[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, kept)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretNamesOf is every `${secret:…}` name that carries a credential of this requirement: its own
|
|
||||||
// name, and each local name it keeps several under (ADR 0094).
|
|
||||||
func secretNamesOf(m Manifest, requirement string) []string {
|
|
||||||
names := []string{requirement}
|
|
||||||
for local := range m.SecretsMany[requirement] {
|
|
||||||
names = append(names, local)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// OptionalProblems is what the module check refuses of a manifest's optional requirements and its
|
|
||||||
// only-when-bound resources (ADR 0330 §4–§6).
|
|
||||||
func OptionalProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
required := map[string]bool{}
|
|
||||||
for _, r := range m.Requires {
|
|
||||||
if required[r] && m.IsOptional(r) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s requires %q twice; say it once, optional or not", m.Module, r))
|
|
||||||
}
|
|
||||||
required[r] = true
|
|
||||||
}
|
|
||||||
for _, r := range optionalRequiresList(m) {
|
|
||||||
if !required[r] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says %q is optional in %s and does not require it", m.Module, r, OptionalRequiresKey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A contribution is to whoever answers the requirement, so it goes with it: contributing to one
|
|
||||||
// that is optional is allowed, and composed only while it is bound.
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
when, marked := r[OnlyWhenBound]
|
|
||||||
on := ""
|
|
||||||
if marked {
|
|
||||||
name, ok := when.(string)
|
|
||||||
switch {
|
|
||||||
case !ok || name == "":
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s says %s %v; it names one requirement the module requires optionally",
|
|
||||||
m.Module, id, OnlyWhenBound, when))
|
|
||||||
continue
|
|
||||||
case fmt.Sprint(r["type"]) != "file":
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s is a %v and says %s: only a file is left out while a requirement is "+
|
|
||||||
"absent (novox/hq ADR 0330 §5)", m.Module, id, r["type"], OnlyWhenBound))
|
|
||||||
continue
|
|
||||||
case !required[name]:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s is delivered only when %q is bound, and %s does not require it",
|
|
||||||
m.Module, id, name, m.Module))
|
|
||||||
continue
|
|
||||||
case !m.IsOptional(name):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s is delivered only when %q is bound, which is a hard requirement and "+
|
|
||||||
"always bound: mark the requirement optional, or drop %s", m.Module, id, name, OnlyWhenBound))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
on = name
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content, _ := r["content"].(string)
|
|
||||||
for _, use := range bound.FindAllStringSubmatch(content, -1) {
|
|
||||||
provision, key, hasDefault := use[1], use[2], use[3] != ""
|
|
||||||
if !m.IsOptional(provision) {
|
|
||||||
if hasDefault {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s gives ${bound:%s:%s} a default, and %q is a hard requirement, always "+
|
|
||||||
"bound: a default it never renders only misleads a reader", m.Module, id, provision, key, provision))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !hasDefault && on != provision {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s says ${bound:%s:%s}, and %q is optional: while it is absent this would "+
|
|
||||||
"be empty. Name its value then — ${bound:%s:%s|default:<value>} — or deliver the file "+
|
|
||||||
"only while it is bound, with \"%s\": %q (novox/hq ADR 0330 §4)",
|
|
||||||
m.Module, id, provision, key, provision, provision, key, OnlyWhenBound, provision))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, provision := range m.Requires {
|
|
||||||
if !m.IsOptional(provision) || on == provision {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, name := range secretNamesOf(m, provision) {
|
|
||||||
if strings.Contains(content, "${secret:"+name+"}") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %s says ${secret:%s}, the credential of %q, which is optional and not "+
|
|
||||||
"delivered while absent: deliver the file only while it is bound, with \"%s\": %q "+
|
|
||||||
"(novox/hq ADR 0330 §6)", m.Module, id, name, provision, OnlyWhenBound, provision))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,616 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Where a module's data is on THIS machine is the assignment's (novox/hq ADR 0112, issue 153).
|
|
||||||
//
|
|
||||||
// A definition names no host path. It declares the directories it owns by id, and the operator's
|
|
||||||
// shared data it needs by id too (an `access`, ADR 0051). A node has a default layout for the
|
|
||||||
// former — <root>/<module>/<id> — and nothing at all for the latter, because shared data is
|
|
||||||
// wherever the operator keeps it. An adopted machine keeps its data where the predecessor put it:
|
|
||||||
// a 40 TB library on its own pool, a configuration on a second disk. Both halves are said on the
|
|
||||||
// assignment, validated the way `endpoints` is — an id the module does not declare is refused,
|
|
||||||
// because a setting that reaches nothing is a mistake — and resolved here, so the host receives
|
|
||||||
// concrete paths exactly as it always has and learns no field.
|
|
||||||
//
|
|
||||||
// {"places": {"config": "/services/sonarr/config",
|
|
||||||
// "data": {"path": "/mnt/plex/data", "owner": "1000:1000"}},
|
|
||||||
// "accesses": {"series": "/storage/media/series",
|
|
||||||
// "downloads": "/storage/downloads"}}
|
|
||||||
//
|
|
||||||
// A placed directory is still the mesh's: created, chowned to the owner the assignment says (or
|
|
||||||
// the manifest's), removed when empty and no longer declared. A placed access is still the
|
|
||||||
// operator's: mounted, never created, chowned or removed.
|
|
||||||
|
|
||||||
// PlacesSetting is the settings key that places a module's declared directories, by id.
|
|
||||||
const PlacesSetting = "places"
|
|
||||||
|
|
||||||
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
|
|
||||||
const AccessesSetting = "accesses"
|
|
||||||
|
|
||||||
// **Placement is not config** (novox/hq ADR 0348 clause 11, issues 651 and 736). `places`, `accesses` and
|
|
||||||
// `networks` — where an assignment's things are — and `endpoints`, `expose`, `reach` and `ports` — where it
|
|
||||||
// is reached and from how far — are facts of an assignment the controller decides, held beside the
|
|
||||||
// assignment and apart from its settings: a Layer carries them in Placement, never in Values, and only
|
|
||||||
// their readers (Places, AccessPlaces, KeptNetworks, Endpoints, Exposure, Reaches, GivenPorts) read them.
|
|
||||||
// A person still says them as keys of a layer through `settings`; LayerOf splits such a layer, so what
|
|
||||||
// they type is routed to the placement and nothing they do changes.
|
|
||||||
|
|
||||||
// placementKeys are the keys of a layer that are placement, not config.
|
|
||||||
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
|
|
||||||
EndpointsSetting: true, ExposeSetting: true, ReachSetting: true, PortsSetting: true}
|
|
||||||
|
|
||||||
// PlacementKeys are the keys of a layer that are placement, not config, sorted: what a settings row must
|
|
||||||
// no longer hold once the move of placement has run (issue 736).
|
|
||||||
func PlacementKeys() []string {
|
|
||||||
keys := make([]string, 0, len(placementKeys))
|
|
||||||
for k := range placementKeys {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is
|
|
||||||
// nil when it holds nothing; the values themselves are not copied.
|
|
||||||
func SplitPlacement(values map[string]any) (config, placement map[string]any) {
|
|
||||||
for key, value := range values {
|
|
||||||
if placementKeys[key] {
|
|
||||||
if placement == nil {
|
|
||||||
placement = map[string]any{}
|
|
||||||
}
|
|
||||||
placement[key] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if config == nil {
|
|
||||||
config = map[string]any{}
|
|
||||||
}
|
|
||||||
config[key] = value
|
|
||||||
}
|
|
||||||
return config, placement
|
|
||||||
}
|
|
||||||
|
|
||||||
// LayerOf is a layer as a person says it — config values and placement in one map — split into the two.
|
|
||||||
func LayerOf(from string, values map[string]any) Layer {
|
|
||||||
config, placement := SplitPlacement(values)
|
|
||||||
if config == nil {
|
|
||||||
config = map[string]any{}
|
|
||||||
}
|
|
||||||
return Layer{From: from, Values: config, Placement: placement}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whole is the layer as a person says it: its values and its placement in one map, a new map.
|
|
||||||
func (l Layer) Whole() map[string]any {
|
|
||||||
out := make(map[string]any, len(l.Values)+len(l.Placement))
|
|
||||||
for k, v := range l.Values {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range l.Placement {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// asSaid is the layers with placement folded back into the values, for what reads a layer whole as it
|
|
||||||
// did while placement was kept in it: today's mergeable file, which takes every key (ADR 0326 §3), and
|
|
||||||
// the judgement that no value holds a line end.
|
|
||||||
func asSaid(layers []Layer) []Layer {
|
|
||||||
out := make([]Layer, len(layers))
|
|
||||||
for i, l := range layers {
|
|
||||||
out[i] = l
|
|
||||||
if len(l.Placement) > 0 {
|
|
||||||
out[i] = Layer{From: l.From, Values: l.Whole(), Default: l.Default}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placement is what an assignment says about one of a module's directories.
|
|
||||||
type Placement struct {
|
|
||||||
// Path is where the directory is on this machine. Absolute.
|
|
||||||
Path string
|
|
||||||
// Owner is "uid:gid" when the assignment overrides the manifest's — the predecessor's data is
|
|
||||||
// owned by whoever it ran as, and that is one machine's fact.
|
|
||||||
Owner string
|
|
||||||
}
|
|
||||||
|
|
||||||
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
|
|
||||||
|
|
||||||
// Where no placement and no access may be, from any route, the controller's terminal too (novox/hq issue 339).
|
|
||||||
//
|
|
||||||
// A placed directory is created and owned by the node-engine as root, with the owner the setting names, and
|
|
||||||
// whatever the module writes into it is written as root; an access is mounted into the module's container,
|
|
||||||
// which may run as root. A place at /etc owned by an account a caller names hands that account the machine,
|
|
||||||
// and an access at / mounts the machine's root into a container. So the machine's own trees are refused here,
|
|
||||||
// before anything is kept or composed, and the node-engine refuses them again where it applies.
|
|
||||||
//
|
|
||||||
// systemTrees are refused at and below: the machine's system, the kernel's, the boot loader's, root's home,
|
|
||||||
// what lives only while the machine runs, the spool (cron's tables are there), the container runtimes' data
|
|
||||||
// (every container's filesystem), /opt, and the node-engine's and the mesh's own state. Any home's .ssh is
|
|
||||||
// refused as well, wherever the home is. systemRoots are
|
|
||||||
// refused at, and wherever a path holds one (an ancestor of /var/lib holds it): each is the parent of every
|
|
||||||
// module's or every person's directories, and owning it is owning all of them.
|
|
||||||
var (
|
|
||||||
systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/run", "/var/run", "/var/lock", "/proc", "/sys",
|
|
||||||
"/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/spool",
|
|
||||||
"/var/lib/docker", "/var/lib/containers", "/var/lib/containerd", "/opt"}
|
|
||||||
systemRoots = []string{"/", "/var", "/var/lib", "/var/cache", "/var/log", "/var/tmp", "/home",
|
|
||||||
"/mnt", "/media", "/srv", "/tmp", "/storage", "/data", "/services"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// systemPath says why a clean absolute path is the machine's own and never a placement's or an access's, or "".
|
|
||||||
func systemPath(path string) string {
|
|
||||||
// Any home's keys, wherever the home is: a .ssh directory is its account's, and the keys and the list of who
|
|
||||||
// may log in as it are in there.
|
|
||||||
for _, part := range strings.Split(path, "/") {
|
|
||||||
if part == ".ssh" {
|
|
||||||
return path + " is an account's .ssh, which holds its keys and who may log in as it"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, tree := range systemTrees {
|
|
||||||
if path == tree || strings.HasPrefix(path, tree+"/") {
|
|
||||||
return path + " is in " + tree + ", the machine's own or the mesh's state"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(tree, path+"/") || path == "/" {
|
|
||||||
return path + " holds " + tree + ", the machine's own or the mesh's state"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, root := range systemRoots {
|
|
||||||
if path == root {
|
|
||||||
return path + " is the parent of every module's or every person's directories"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where no directory or file a module's definition resolves to may be (novox/hq issue 496).
|
|
||||||
//
|
|
||||||
// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to <root>/docker: with the
|
|
||||||
// default root, /var/lib/docker, every container's filesystem. systemPath judged only the `places` and `accesses`
|
|
||||||
// settings, so the default layout and a definition's own paths reached the merge gate unjudged; it composed every
|
|
||||||
// machine and passed, and only the node-engine refused the directory, at apply, failing the walk.
|
|
||||||
//
|
|
||||||
// **Judged where a definition is judged, never where a machine is composed.** resolvedPathProblems runs in
|
|
||||||
// ParseManifest, which every route a definition takes into the mesh passes: `module check`, registration of a
|
|
||||||
// build (the builder's and the registry verbs'), and the merge gate's reading of the changed repository. A
|
|
||||||
// refusal there stops one definition before it reaches any machine. Composition reads registered manifests
|
|
||||||
// without ParseManifest, and judges nothing of this: refusing there would fail the whole machine's declaration and
|
|
||||||
// freeze every module on it for one module's path, where the node-engine fails only that resource.
|
|
||||||
//
|
|
||||||
// **The node-engine's rules, no more** (mesh-host's internal/apply/placement_guard.go, with files judged as
|
|
||||||
// directories are after novox/hq issue 495, rule 6). A path is refused when it is one of protectedRoots or holds
|
|
||||||
// one, when it is at or below a tree in forbiddenBelow, or at or below one of engineTrees and its module is not
|
|
||||||
// the node-engine's. What the engine judges with what only the machine knows stays the engine's: where the
|
|
||||||
// runtimes really keep their data, links, the accounts' homes, a directory's owner below /etc. The lists are the
|
|
||||||
// engine's own words, and a test (engine_guard_test.go) holds them equal to mesh-host's beside this repository.
|
|
||||||
// systemPath stays the stricter rule for a setting: a setting is an operator's word about one machine, and the
|
|
||||||
// trees it lists (/etc, /usr, /run, the mesh's own) are where the mesh's own modules write by design.
|
|
||||||
var (
|
|
||||||
protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
|
|
||||||
"/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin",
|
|
||||||
"/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin",
|
|
||||||
"/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp",
|
|
||||||
"/var/spool"}
|
|
||||||
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
|
|
||||||
"/var/lib/containers", "/var/lib/containerd"}
|
|
||||||
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// engineModule is the node-engine's own module, the one that places in engineTrees.
|
|
||||||
const engineModule = "mesh-host"
|
|
||||||
|
|
||||||
// enginePath says why the node-engine refuses a directory or file at path for module, or "".
|
|
||||||
func enginePath(path, module string) string {
|
|
||||||
path = filepath.Clean(path)
|
|
||||||
if !filepath.IsAbs(path) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") }
|
|
||||||
for _, root := range protectedRoots {
|
|
||||||
if path == root || path == "/" || strings.HasPrefix(root, path+"/") {
|
|
||||||
return root + " is one of the machine's own directories, and owning it is owning everything in it"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, tree := range forbiddenBelow {
|
|
||||||
if atOrBelow(tree) {
|
|
||||||
return "nothing is placed in " + tree
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if module != engineModule {
|
|
||||||
for _, tree := range engineTrees {
|
|
||||||
if atOrBelow(tree) {
|
|
||||||
return tree + " is the node-engine's own, placed in by its own module alone"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the
|
|
||||||
// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder
|
|
||||||
// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge.
|
|
||||||
func (m Manifest) resolvedPathProblems() []string {
|
|
||||||
dirs := dirsFor(m, Rendering{})
|
|
||||||
accesses := map[string]string{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" && a.Path != "" {
|
|
||||||
accesses[a.ID] = a.Path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
kind := fmt.Sprint(r["type"])
|
|
||||||
if kind != "directory" && kind != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
path, _ := r["path"].(string)
|
|
||||||
if kind == "directory" && path == "" {
|
|
||||||
path = dirs[id]
|
|
||||||
}
|
|
||||||
path, _ = dirFill(path, dirs, m.Module)
|
|
||||||
path, _ = accessFill(path, accesses, m.Module)
|
|
||||||
if path == "" || strings.Contains(path, "${") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if why := enginePath(path, m.Module); why != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+
|
|
||||||
"A module's directories and files are judged when its definition is, so the merge gate refuses it "+
|
|
||||||
"before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessRef is how a module names one of its accesses: ${access:<id>}.
|
|
||||||
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
|
||||||
|
|
||||||
// Places reads where this node places the module's directories, by directory id.
|
|
||||||
//
|
|
||||||
// It refuses an id the module declares no directory for, a path that is not absolute, and an
|
|
||||||
// owner that is not uid:gid. A directory the assignment does not mention keeps the manifest's
|
|
||||||
// stated path or the node's default layout.
|
|
||||||
func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
declared[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := map[string]Placement{}
|
|
||||||
for _, layer := range layers {
|
|
||||||
raw, ok := layer.Placement[PlacesSetting]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blocks, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a { directory: path | { path, owner } } map, and %q set it to something else",
|
|
||||||
m.Module, PlacesSetting, layer.From)
|
|
||||||
}
|
|
||||||
for id, body := range blocks {
|
|
||||||
if !declared[id] {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s places the directory %q, which it does not declare — the setting reaches "+
|
|
||||||
"nothing. It declares %s", m.Module, id, orNothing(namesOfDirs(directoriesOf(m))))
|
|
||||||
}
|
|
||||||
p := out[id]
|
|
||||||
switch v := body.(type) {
|
|
||||||
case string:
|
|
||||||
p.Path = strings.TrimSpace(v)
|
|
||||||
case map[string]any:
|
|
||||||
if path, said := v["path"]; said {
|
|
||||||
text, _ := path.(string)
|
|
||||||
p.Path = strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
if owner, said := v["owner"]; said {
|
|
||||||
text, _ := owner.(string)
|
|
||||||
if !ownerShape.MatchString(strings.TrimSpace(text)) {
|
|
||||||
return nil, fmt.Errorf("%s places %q with owner %v; an owner is uid:gid, numeric",
|
|
||||||
m.Module, id, owner)
|
|
||||||
}
|
|
||||||
p.Owner = strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%s places %q with %v; a placement is a path, or { path, owner }",
|
|
||||||
m.Module, id, body)
|
|
||||||
}
|
|
||||||
if p.Path == "" {
|
|
||||||
return nil, fmt.Errorf("%s places %q without a path", m.Module, id)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(p.Path, "/") {
|
|
||||||
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
|
|
||||||
}
|
|
||||||
p.Path = filepath.Clean(p.Path)
|
|
||||||
if why := systemPath(p.Path); why != "" {
|
|
||||||
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine creates and owns a placed "+
|
|
||||||
"directory as root, with the owner the setting names — no placement is ever there "+
|
|
||||||
"(novox/hq issue 339)", m.Module, id, p.Path, why)
|
|
||||||
}
|
|
||||||
out[id] = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccessPlaces reads where this node keeps the operator's data the module accesses, by access id.
|
|
||||||
//
|
|
||||||
// It refuses an id the module declares no access under, and a path that is not absolute. An
|
|
||||||
// access declared by path alone cannot be placed — it has no name to place it by.
|
|
||||||
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
declared[a.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, layer := range layers {
|
|
||||||
raw, ok := layer.Placement[AccessesSetting]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blocks, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a { access: path } map, and %q set it to something else",
|
|
||||||
m.Module, AccessesSetting, layer.From)
|
|
||||||
}
|
|
||||||
for id, body := range blocks {
|
|
||||||
if !declared[id] {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s places the access %q, which it does not declare — the setting reaches "+
|
|
||||||
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
|
|
||||||
}
|
|
||||||
path, _ := body.(string)
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
|
||||||
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
|
|
||||||
m.Module, id, body)
|
|
||||||
}
|
|
||||||
path = filepath.Clean(path)
|
|
||||||
if why := systemPath(path); why != "" {
|
|
||||||
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
|
|
||||||
"module's container — no access is ever there (novox/hq issue 339)", m.Module, id, path, why)
|
|
||||||
}
|
|
||||||
out[id] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// placedAccess is one access with the path it resolves to on this node.
|
|
||||||
type placedAccess struct {
|
|
||||||
ID string
|
|
||||||
Path string
|
|
||||||
Mode string
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessesFor is every access of a module with its path on this node: the assignment's where it
|
|
||||||
// placed one, the definition's where it carries a default, and refused where neither says — an
|
|
||||||
// access that resolves to nowhere would reach the machine as a mount of nothing.
|
|
||||||
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
|
|
||||||
placed, err := AccessPlaces(m, layers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var out []placedAccess
|
|
||||||
byID := map[string]string{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
path := a.Path
|
|
||||||
if a.ID != "" {
|
|
||||||
if at, said := placed[a.ID]; said {
|
|
||||||
path = at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if path == "" {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"%s accesses %q, and nothing says where that is on this node — the definition "+
|
|
||||||
"carries no path (it must not, novox/hq ADR 0112) and the assignment places "+
|
|
||||||
"none. Set %s: {%q: \"/where/it/is\"}",
|
|
||||||
m.Module, a.ID, AccessesSetting, a.ID)
|
|
||||||
}
|
|
||||||
out = append(out, placedAccess{ID: a.ID, Path: path, Mode: a.At()})
|
|
||||||
if a.ID != "" {
|
|
||||||
byID[a.ID] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, byID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessFill resolves every ${access:…} in one string, or refuses a reference naming no access.
|
|
||||||
func accessFill(s string, accesses map[string]string, module string) (string, error) {
|
|
||||||
var missing error
|
|
||||||
out := accessRef.ReplaceAllStringFunc(s, func(ref string) string {
|
|
||||||
id := accessRef.FindStringSubmatch(ref)[1]
|
|
||||||
path, has := accesses[id]
|
|
||||||
if !has {
|
|
||||||
missing = fmt.Errorf(
|
|
||||||
"%s says ${access:%s}, and %s declares no access %q. It declares %s",
|
|
||||||
module, id, module, id, orNothing(namesOfAccessIDs(accesses)))
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
})
|
|
||||||
return out, missing
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessInto fills every ${access:…} a resource carries — in its path, its content, its mounts,
|
|
||||||
// its environment and its env-files — with the path this node resolved for it. The same walk as
|
|
||||||
// dirInto, for the same reason: a literal `${access:x}` reaching the machine would be mounted as
|
|
||||||
// a directory called that.
|
|
||||||
func accessInto(resource map[string]any, accesses map[string]string, module string) error {
|
|
||||||
if !mentionsAccess(resource) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fill := func(s string) (string, error) { return accessFill(s, accesses, module) }
|
|
||||||
var err error
|
|
||||||
if path, ok := resource["path"].(string); ok {
|
|
||||||
if resource["path"], err = fill(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if content, ok := resource["content"].(string); ok {
|
|
||||||
if resource["content"], err = fill(content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
list, ok := resource[field].([]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled := make([]any, len(list))
|
|
||||||
for i, v := range list {
|
|
||||||
filled[i] = v
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
if filled[i], err = fill(s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource[field] = filled
|
|
||||||
}
|
|
||||||
if env, ok := resource["env"].(map[string]any); ok {
|
|
||||||
filled := make(map[string]any, len(env))
|
|
||||||
for key, v := range env {
|
|
||||||
filled[key] = v
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
if filled[key], err = fill(s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["env"] = filled
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mentionsAccess(resource map[string]any) bool {
|
|
||||||
for _, field := range []string{"path", "content"} {
|
|
||||||
if s, ok := resource[field].(string); ok && accessRef.MatchString(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
if list, ok := resource[field].([]any); ok {
|
|
||||||
for _, v := range list {
|
|
||||||
if s, ok := v.(string); ok && accessRef.MatchString(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if env, ok := resource["env"].(map[string]any); ok {
|
|
||||||
for _, v := range env {
|
|
||||||
if s, ok := v.(string); ok && accessRef.MatchString(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerInto gives a placed directory the owner the assignment said, where it said one. The
|
|
||||||
// manifest's owner is what the image expects on any machine; the assignment's is what this
|
|
||||||
// machine's data already is.
|
|
||||||
func ownerInto(resource map[string]any, placed map[string]Placement) {
|
|
||||||
if fmt.Sprint(resource["type"]) != "directory" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p, ok := placed[fmt.Sprint(resource["id"])]; ok && p.Owner != "" {
|
|
||||||
resource["owner"] = p.Owner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unknownAccessRefs is every ${access:…} in the definition that names no access the definition
|
|
||||||
// declares by id — refused where the author is, as unknownDirRefs does for directories.
|
|
||||||
func (m Manifest) unknownAccessRefs() []string {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
declared[a.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var problems []string
|
|
||||||
refuse := func(s string, where any) {
|
|
||||||
for _, match := range accessRef.FindAllStringSubmatch(s, -1) {
|
|
||||||
id := match[1]
|
|
||||||
if declared[id] || seen[id] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[id] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+
|
|
||||||
"cannot place would reach the machine as a literal path",
|
|
||||||
m.Module, id, where, id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
for _, field := range []string{"path", "content"} {
|
|
||||||
if s, ok := r[field].(string); ok {
|
|
||||||
refuse(s, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
if list, ok := r[field].([]any); ok {
|
|
||||||
for _, v := range list {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
refuse(s, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if env, ok := r["env"].(map[string]any); ok {
|
|
||||||
for _, v := range env {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
refuse(s, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(problems)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func directoriesOf(m Manifest) map[string]string {
|
|
||||||
dirs := map[string]string{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
dirs[fmt.Sprint(r["id"])] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dirs
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfAccesses(m Manifest) []string {
|
|
||||||
var names []string
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
names = append(names, fmt.Sprintf("%q", a.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfAccessIDs(accesses map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for id := range accesses {
|
|
||||||
names = append(names, fmt.Sprintf("%q", id))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Telling a module which port it was given.
|
|
||||||
//
|
|
||||||
// **The mesh assigns the machine-side port and a module does not choose one**
|
|
||||||
// ([ADR 0038](../../02-DECISIONS/0038-the-mesh-assigns-the-port.md)); on a node given one for a
|
|
||||||
// module it is the operator's number rather than the mesh's
|
|
||||||
// ([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)). For a
|
|
||||||
// container's own listening socket that is invisible: the mesh rewrites `ports` into
|
|
||||||
// `assigned:wanted`, the software inside binds the number it has always bound, and the machine
|
|
||||||
// publishes a different one.
|
|
||||||
//
|
|
||||||
// **Two kinds of resource have no such layer.**
|
|
||||||
//
|
|
||||||
// - **A process** runs on the machine, there is nothing to rewrite, and it binds whatever its
|
|
||||||
// configuration says — so without this, every process binds the number written in its own
|
|
||||||
// config, two modules declaring the same one collide, and the mesh's whole reason for
|
|
||||||
// assigning ports is defeated by the resource kind that most needs it.
|
|
||||||
// - **A container that DIALS the machine** — a module's own sidecar reaching the service beside
|
|
||||||
// it over the machine's loopback — is told that address in its environment, and the mapping
|
|
||||||
// that saves the listener does nothing for the caller: what it must dial is the machine-side
|
|
||||||
// number, which is exactly the one the module cannot know (novox/hq 04-ISSUES/088).
|
|
||||||
//
|
|
||||||
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
|
|
||||||
// listen on", and the module writes that where it would otherwise have written a literal — in a
|
|
||||||
// file's content, or in a value of a container's or a process's `env`.
|
|
||||||
//
|
|
||||||
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
|
|
||||||
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
|
|
||||||
// witness of, and the host learns no new field. That is what separates this from
|
|
||||||
// [ADR 0086](../../02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md), which refuses a
|
|
||||||
// `${secret:…}` in an `env` outright: the objection there is to the value being in an environment
|
|
||||||
// at all, not to who fills it in.
|
|
||||||
//
|
|
||||||
// **It answers with the machine's number, wherever it is written.** A container reaching a sibling
|
|
||||||
// over the runtime's own network reaches it on the port inside that container and goes on writing
|
|
||||||
// that number literally — it is a number the module does control. This is for the machine side,
|
|
||||||
// which is the side nobody but the mesh can know.
|
|
||||||
|
|
||||||
// ofPort is where a module asks which port it was given: ${port:<the port its software uses>}.
|
|
||||||
var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`)
|
|
||||||
|
|
||||||
// portsUsed are the ports a written value asks about, first appearance first.
|
|
||||||
func portsUsed(content string) []int {
|
|
||||||
var used []int
|
|
||||||
seen := map[int]bool{}
|
|
||||||
for _, m := range ofPort.FindAllStringSubmatch(content, -1) {
|
|
||||||
n, err := strconv.Atoi(m[1])
|
|
||||||
if err != nil || seen[n] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[n] = true
|
|
||||||
used = append(used, n)
|
|
||||||
}
|
|
||||||
return used
|
|
||||||
}
|
|
||||||
|
|
||||||
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
|
|
||||||
// file's content, and in a value of a container's or a process's environment.
|
|
||||||
//
|
|
||||||
// **A process's environment is a container's** (novox/hq to-be 38 WP4c). A module's code moving out
|
|
||||||
// of its container becomes a process on the machine and still has to be told what the container
|
|
||||||
// was told; filled for one kind and not the other, the literal reached the process and was read as
|
|
||||||
// a port, and the modules that moved first wrote their run-once steps a 0600 env file instead.
|
|
||||||
//
|
|
||||||
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
|
|
||||||
// key is: the module is asking about something it never declared, and the answer would be a guess.
|
|
||||||
// Left alone, the literal would be written into a configuration file, or handed to a process as
|
|
||||||
// its environment, and read as a port number.
|
|
||||||
func portInto(resource map[string]any, module string, listens []Listening, with Rendering) error {
|
|
||||||
switch fmt.Sprint(resource["type"]) {
|
|
||||||
case "file":
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
filled, err := portsFilledInto(content,
|
|
||||||
fmt.Sprintf("%s has a file that", module), module, listens, with)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resource["content"] = filled
|
|
||||||
|
|
||||||
case "container", "process":
|
|
||||||
env, ok := resource["env"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// In a stated order, so a container with two bad values always refuses on the same one.
|
|
||||||
named := make([]string, 0, len(env))
|
|
||||||
for key := range env {
|
|
||||||
named = append(named, key)
|
|
||||||
}
|
|
||||||
sort.Strings(named)
|
|
||||||
|
|
||||||
// **A fresh map, and only when something changes.** This map came out of the module's
|
|
||||||
// manifest and the resource around it is a shallow copy, so filling a value in place would
|
|
||||||
// change what the catalogue holds for every other machine running the module.
|
|
||||||
var filled map[string]any
|
|
||||||
for _, key := range named {
|
|
||||||
written, ok := env[key].(string)
|
|
||||||
if !ok || len(portsUsed(written)) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := portsFilledInto(written,
|
|
||||||
fmt.Sprintf("%s's %s %s sets %s to something that",
|
|
||||||
module, resource["type"], resource["name"], key), module, listens, with)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if filled == nil {
|
|
||||||
filled = map[string]any{}
|
|
||||||
for k, v := range env {
|
|
||||||
filled[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filled[key] = value
|
|
||||||
}
|
|
||||||
if filled != nil {
|
|
||||||
resource["env"] = filled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// portsFilledInto answers every ${port:…} in one written value, or refuses. `where` names the
|
|
||||||
// place it was written, so a refusal is one edit from right whichever kind of resource it came
|
|
||||||
// out of.
|
|
||||||
func portsFilledInto(written, where, module string, listens []Listening, with Rendering) (
|
|
||||||
string, error) {
|
|
||||||
for _, wanted := range portsUsed(written) {
|
|
||||||
var declared bool
|
|
||||||
for _, l := range listens {
|
|
||||||
if l.Port == wanted {
|
|
||||||
declared = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !declared {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"%s says ${port:%d}, and %s does not say it listens on %d. A module is told the "+
|
|
||||||
"port it was given for something it declared, and %s",
|
|
||||||
where, wanted, module, wanted, orNoListens(listens))
|
|
||||||
}
|
|
||||||
written = strings.ReplaceAll(written, fmt.Sprintf("${port:%d}", wanted),
|
|
||||||
strconv.Itoa(with.machinePort(module, wanted)))
|
|
||||||
}
|
|
||||||
return written, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// orNoListens says what would have worked, so a refusal is one edit from right.
|
|
||||||
func orNoListens(listens []Listening) string {
|
|
||||||
if len(listens) == 0 {
|
|
||||||
return "it declares no ports at all"
|
|
||||||
}
|
|
||||||
said := make([]string, 0, len(listens))
|
|
||||||
for _, l := range listens {
|
|
||||||
said = append(said, strconv.Itoa(l.Port))
|
|
||||||
}
|
|
||||||
return "it declares " + strings.Join(said, ", ")
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661).
|
|
||||||
//
|
|
||||||
// **A fixed, reviewable list, owned by the build.** Mesh config and node config hold values only for the keys
|
|
||||||
// in the catalogue's `predefined.json`, at the root of its repository: each key with its type, a description
|
|
||||||
// including its unit, its validation (enum, minimum and maximum, pattern) and whether it is trusted. A module's
|
|
||||||
// value may fall back to one of them, of the same type (ADR 0348 §2, §4).
|
|
||||||
//
|
|
||||||
// **Read where a manifest is judged, from the tree it is judged in.** A build reads the file at the root of the
|
|
||||||
// repository at the commit it builds, `module check` at the root of the repository above each manifest, the
|
|
||||||
// merge gate at the root of the tree under check; each refuses a file that is not exactly a list of keys, and
|
|
||||||
// judges every manifest it reads against it (ParseManifestWith). Everywhere else — registration, a stored build
|
|
||||||
// read again — a manifest is judged against the list the mesh published (PublishedPredefined): the newest trunk
|
|
||||||
// build's of the catalogue, which the controller keeps and loads with its store.
|
|
||||||
|
|
||||||
// PredefinedFile is the list's file at the root of the catalogue's repository.
|
|
||||||
const PredefinedFile = "predefined.json"
|
|
||||||
|
|
||||||
// CatalogueRepository is the name of the repository whose list the mesh publishes: the catalogue's, by the name
|
|
||||||
// a check finds it beside under (internal/builder's check, ADR 0121).
|
|
||||||
const CatalogueRepository = "mesh-catalog"
|
|
||||||
|
|
||||||
// PredefinedKey is one key of the list: its type, its description, its validation and whether it is trusted
|
|
||||||
// (ADR 0348 §7). It has no default: a module's value falling back to it has its own.
|
|
||||||
type PredefinedKey struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Enum []any `json:"enum,omitempty"`
|
|
||||||
Minimum *float64 `json:"minimum,omitempty"`
|
|
||||||
Maximum *float64 `json:"maximum,omitempty"`
|
|
||||||
Pattern string `json:"pattern,omitempty"`
|
|
||||||
Trusted bool `json:"trusted,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PredefinedList is the list by key. Nil is no list at all, which is not the same as an empty one: a value
|
|
||||||
// naming a key is refused then saying the list is not present.
|
|
||||||
type PredefinedList map[string]PredefinedKey
|
|
||||||
|
|
||||||
// ParsePredefined reads predefined.json strictly: one JSON object of keys, each with exactly the fields of a
|
|
||||||
// PredefinedKey, and every key judged as a module's config value is (PredefinedProblems). Anything else is
|
|
||||||
// refused whole, naming each problem — a list half read would let a module fall back to a key nobody reviewed.
|
|
||||||
func ParsePredefined(raw []byte) (PredefinedList, error) {
|
|
||||||
var keys map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", PredefinedFile, err)
|
|
||||||
}
|
|
||||||
if keys == nil {
|
|
||||||
return nil, fmt.Errorf("%s is not a JSON object of keys: it says null", PredefinedFile)
|
|
||||||
}
|
|
||||||
list := PredefinedList{}
|
|
||||||
var problems []string
|
|
||||||
for _, key := range sortedKeys(keys) {
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(keys[key]))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
var k PredefinedKey
|
|
||||||
if err := decoder.Decode(&k); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%q is not a predefined key's declaration (type, description, "+
|
|
||||||
"enum, minimum, maximum, pattern, trusted): %v", key, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
list[key] = k
|
|
||||||
}
|
|
||||||
problems = append(problems, PredefinedProblems(list)...)
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return nil, fmt.Errorf("%s is refused: %s", PredefinedFile, strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PredefinedProblems is every way a key of the list is declared wrongly, by the rules a module's config value
|
|
||||||
// is held to (ADR 0348 §4, §7, §11): a usable name that is not placement, a type, a description, validation
|
|
||||||
// that fits the type, and an object or a list only when trusted.
|
|
||||||
func PredefinedProblems(list PredefinedList) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, key := range sortedKeys(list) {
|
|
||||||
k := list[key]
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
problems = append(problems, fmt.Sprintf("the predefined key %q: ", key)+fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
if !settingRef.MatchString("${setting:" + key + "}") {
|
|
||||||
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if meshWords[key] || accountFacts[key] {
|
|
||||||
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch k.Type {
|
|
||||||
case ConfigNumber, ConfigString, ConfigBoolean:
|
|
||||||
case ConfigObject, ConfigList:
|
|
||||||
if !k.Trusted {
|
|
||||||
say("is %s and not trusted: an object or a list is trusted, \"trusted\": true (novox/hq ADR 0348 §4)", k.Type)
|
|
||||||
}
|
|
||||||
case "":
|
|
||||||
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
|
|
||||||
default:
|
|
||||||
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", k.Type)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(k.Description) == "" {
|
|
||||||
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §7)")
|
|
||||||
}
|
|
||||||
for _, wrong := range validationProblems(SettingDeclaration{Type: k.Type, Enum: k.Enum, Minimum: k.Minimum,
|
|
||||||
Maximum: k.Maximum, Pattern: k.Pattern}) {
|
|
||||||
say("%s", wrong)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadPredefined is the list in predefined.json at a repository's root, and nil with no error when the root
|
|
||||||
// holds none: a repository other than the catalogue, or the catalogue before the file.
|
|
||||||
func ReadPredefined(root string) (PredefinedList, error) {
|
|
||||||
path := filepath.Join(root, PredefinedFile)
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
return nil, fmt.Errorf("%s is %s, not a plain file", path, kindOf(info.Mode()))
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ParsePredefined(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepositoryRoot is the nearest directory at or above dir holding `.git`: the root of the repository a manifest
|
|
||||||
// is in, where `module check` reads the list. Empty when none is: a manifest outside a repository is judged
|
|
||||||
// against no list.
|
|
||||||
func RepositoryRoot(dir string) string {
|
|
||||||
at, err := filepath.Abs(dir)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
if _, err := os.Lstat(filepath.Join(at, ".git")); err == nil {
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
up := filepath.Dir(at)
|
|
||||||
if up == at {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
at = up
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PredefinedAbove is the list at the root of the repository above dir (RepositoryRoot), nil when there is no
|
|
||||||
// repository or it holds no list.
|
|
||||||
func PredefinedAbove(dir string) (PredefinedList, error) {
|
|
||||||
root := RepositoryRoot(dir)
|
|
||||||
if root == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return ReadPredefined(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canonical is the list as JSON with its keys sorted: what a build carries and the controller keeps, so one list
|
|
||||||
// is one text.
|
|
||||||
func (l PredefinedList) Canonical() ([]byte, error) {
|
|
||||||
if l == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return json.Marshal(map[string]PredefinedKey(l))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys is the list's keys, sorted.
|
|
||||||
func (l PredefinedList) Keys() []string {
|
|
||||||
out := make([]string, 0, len(l))
|
|
||||||
for k := range l {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// NamesPredefined is whether any value of the manifest falls back to a predefined key.
|
|
||||||
func (m Manifest) NamesPredefined() bool {
|
|
||||||
for _, d := range m.Settings {
|
|
||||||
if d.Predefined != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishedPredefined is the list the mesh published, which a manifest is judged against where no tree is read.
|
|
||||||
var publishedPredefined struct {
|
|
||||||
sync.RWMutex
|
|
||||||
list PredefinedList
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsePredefined makes list the one a manifest is judged against where no tree is read: what the controller
|
|
||||||
// loads from its store, and what it keeps when a newer trunk build of the catalogue publishes one. Nil leaves
|
|
||||||
// the list it has: a store that holds none yet never empties one already loaded.
|
|
||||||
func UsePredefined(list PredefinedList) {
|
|
||||||
if list == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
publishedPredefined.Lock()
|
|
||||||
defer publishedPredefined.Unlock()
|
|
||||||
publishedPredefined.list = list
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishedPredefined is the list a manifest is judged against where no tree is read; nil when none is loaded.
|
|
||||||
func PublishedPredefined() PredefinedList {
|
|
||||||
publishedPredefined.RLock()
|
|
||||||
defer publishedPredefined.RUnlock()
|
|
||||||
return publishedPredefined.list
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplacePublished sets the published list, nil included, and answers the one it replaced: for a test that
|
|
||||||
// judges against a list of its own and puts the mesh's back.
|
|
||||||
func ReplacePublished(list PredefinedList) (was PredefinedList) {
|
|
||||||
publishedPredefined.Lock()
|
|
||||||
defer publishedPredefined.Unlock()
|
|
||||||
was, publishedPredefined.list = publishedPredefined.list, list
|
|
||||||
return was
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadTreeManifestWritten is the manifest in a module's directory of a repository tree as written, still naming its
|
|
||||||
// templates, judged against the list of predefined keys at the tree's root as the merge gate judges it
|
|
||||||
// (cmd/mesh-controller). What a build resolves, given the text it reads back from the archive it packs the templates
|
|
||||||
// into (Built.Texts, ReadTemplates).
|
|
||||||
func ReadTreeManifestWritten(root, dir string) (Manifest, error) {
|
|
||||||
list, err := ReadPredefined(root)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
return ParseManifestWith(raw, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadTreeManifest is ReadTreeManifestWritten with each template's text read from the module's directory as the
|
|
||||||
// merge gate reads it (WithTemplatesFrom). For a check that composes a tree's manifests, so what it judges is what
|
|
||||||
// the real check judges.
|
|
||||||
func ReadTreeManifest(root, dir string) (Manifest, error) {
|
|
||||||
m, err := ReadTreeManifestWritten(root, dir)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
return WithTemplatesFrom(m, dir)
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProcessBusField is the key a process resource declares its bus on. Controller-side only: never composed
|
|
||||||
// onto the node-engine's process resource (novox/hq ADR 0436 §1) — the wire declaration is mesh-host's own,
|
|
||||||
// vendored, with a strict decoder that refuses an unknown key.
|
|
||||||
const ProcessBusField = "bus"
|
|
||||||
|
|
||||||
// ProcessBus is what one process of a module does on the bus (novox/hq ADR 0436 §1, §2, §3, §5): its own bus
|
|
||||||
// account, granted exactly this and nothing of the module's other grants or the tool runner's — never a seat
|
|
||||||
// to hold or serve, never a tool call or seat verb answered, never a restricted stream heard, never a
|
|
||||||
// warrant published (§3, §5).
|
|
||||||
type ProcessBus struct {
|
|
||||||
// Emits are the events this process emits, named locally, as the module's own Emits names them
|
|
||||||
// (EmitsAll): a process emits only what its module may emit as a whole.
|
|
||||||
Emits []string `json:"emits,omitempty"`
|
|
||||||
// Queues are the module's own work-queue streams (ADR 0436 §6) this process consumes.
|
|
||||||
Queues []string `json:"queues,omitempty"`
|
|
||||||
// State is the module's own state, by local name, this process reads and writes.
|
|
||||||
State []string `json:"state,omitempty"`
|
|
||||||
// Asks says whether this process reads the router's records of this module's own asks.
|
|
||||||
Asks bool `json:"asks,omitempty"`
|
|
||||||
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
|
|
||||||
// seat:<seat>.<verb> (as Manifest.Invokes), or * for every tool on the mesh.
|
|
||||||
Invokes []string `json:"invokes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or
|
|
||||||
// anything else that would answer a tool call or a seat's verb — is refused here, by name (novox/hq ADR
|
|
||||||
// 0436 §3).
|
|
||||||
func (b *ProcessBus) UnmarshalJSON(raw []byte) error {
|
|
||||||
type plain ProcessBus
|
|
||||||
var p plain
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&p); err != nil {
|
|
||||||
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
*b = ProcessBus(p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// processBusOf is the bus a process resource declares, and whether it declared one at all.
|
|
||||||
func processBusOf(r map[string]any) (bus ProcessBus, present bool, err error) {
|
|
||||||
raw, present := r["bus"]
|
|
||||||
if !present {
|
|
||||||
return ProcessBus{}, false, nil
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(raw)
|
|
||||||
if err != nil {
|
|
||||||
return ProcessBus{}, true, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &bus); err != nil {
|
|
||||||
return ProcessBus{}, true, err
|
|
||||||
}
|
|
||||||
return bus, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessBuses is every process of this module that declares bus, by its id (the manifest's own resource
|
|
||||||
// id, novox/hq ADR 0436, settled by the lead) — what the grant composition reads to grant a process
|
|
||||||
// account exactly what it emits, consumes and calls (novox/hq ADR 0436 §2).
|
|
||||||
func (m Manifest) ProcessBuses() map[string]ProcessBus {
|
|
||||||
var out map[string]ProcessBus
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "process" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b, present, err := processBusOf(r)
|
|
||||||
if !present || err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
out = map[string]ProcessBus{}
|
|
||||||
}
|
|
||||||
out[fmt.Sprint(r["id"])] = b
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessBusProblems is what is wrong with a manifest's processes' bus declarations (novox/hq ADR 0436 §1,
|
|
||||||
// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or
|
|
||||||
// seat verb's shape on invokeProblems.
|
|
||||||
func ProcessBusProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
streams := map[string]bool{}
|
|
||||||
for _, s := range m.Streams {
|
|
||||||
streams[s.Name] = true
|
|
||||||
}
|
|
||||||
state := map[string]bool{}
|
|
||||||
for _, s := range m.State {
|
|
||||||
state[s.Name] = true
|
|
||||||
}
|
|
||||||
emits := map[string]bool{}
|
|
||||||
for _, e := range m.EmitsAll() {
|
|
||||||
emits[e] = true
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "process" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
if _, present := r["bus"]; !present {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b, _, err := processBusOf(r)
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's process %s declares bus: %v", m.Module, id, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
user, _ := r["user"].(string)
|
|
||||||
problems = append(problems, processAccountProblems(m, id, user)...)
|
|
||||||
for _, e := range b.Emits {
|
|
||||||
if !emits[e] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's process %s's bus emits %q, which %s does not declare as its own (novox/hq ADR 0436 §1)",
|
|
||||||
m.Module, id, e, m.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, q := range b.Queues {
|
|
||||||
if !streams[q] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's process %s's bus queues %q, which %s does not declare as its own stream (novox/hq "+
|
|
||||||
"ADR 0436 §1)", m.Module, id, q, m.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range b.State {
|
|
||||||
if !state[s] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's process %s's bus reads and writes state %q, which %s does not declare as its own "+
|
|
||||||
"(novox/hq ADR 0436 §1)", m.Module, id, s, m.Module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, t := range b.Invokes {
|
|
||||||
if !invokeNameValid(t) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's process %s's bus invokes %q, which does not name a tool: a process invokes "+
|
|
||||||
"<module>.<tool>, or a role's seat:<seat>.<verb> (novox/hq ADR 0436 §1)", m.Module, id, t))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// processAccountProblems is what is wrong with one process's bus account (novox/hq ADR 0436 §1, §2): an
|
|
||||||
// account the module makes — a `user` resource of that name — never root and never the operator account,
|
|
||||||
// which a manifest cannot say safely (a node with no account has nothing to resolve it to, novox/hq ADR
|
|
||||||
// 0436, settled by the lead).
|
|
||||||
func processAccountProblems(m Manifest, processID, user string) []string {
|
|
||||||
switch {
|
|
||||||
case user == "":
|
|
||||||
return []string{fmt.Sprintf(
|
|
||||||
"%s's process %s declares bus and names no account; a process account is a user resource the "+
|
|
||||||
"module makes, never root and never the operator account (novox/hq ADR 0436 §1, §2)",
|
|
||||||
m.Module, processID)}
|
|
||||||
case user == "root":
|
|
||||||
return []string{fmt.Sprintf(
|
|
||||||
"%s's process %s runs as root; a process account is the module's own, never root (novox/hq ADR "+
|
|
||||||
"0436 §1, §2)", m.Module, processID)}
|
|
||||||
case user == "${machine:account}":
|
|
||||||
return []string{fmt.Sprintf(
|
|
||||||
"%s's process %s runs as the operator account; a process account is the module's own, never the "+
|
|
||||||
"operator's (novox/hq ADR 0436 §1, §2)", m.Module, processID)}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == user {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return []string{fmt.Sprintf(
|
|
||||||
"%s's process %s runs as %s, which it does not make: a user resource named %s (novox/hq ADR 0436 §1, "+
|
|
||||||
"§2)", m.Module, processID, user, user)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// invokeNameValid says a module or process's invoked entry names a tool: <module>.<tool>, a role's
|
|
||||||
// seat:<seat>.<verb>, or * for every tool on the mesh (novox/hq ADR 0152, ADR 0436 §1).
|
|
||||||
func invokeNameValid(t string) bool {
|
|
||||||
if t == "*" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
t = strings.TrimPrefix(t, "seat:")
|
|
||||||
first, second, named := strings.Cut(t, ".")
|
|
||||||
return named && name.MatchString(first) && toolName.MatchString(second)
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8).
|
|
||||||
//
|
|
||||||
// **A seat of its own on every node.** Reading a file, searching one, listing a directory, the owner and mode
|
|
||||||
// of each component of a path, and looking up an account or a group are the reads ADR 0268 §5 has narrow tools
|
|
||||||
// take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every
|
|
||||||
// node needs them, the control-node included, and another module could offer them instead of the tool
|
|
||||||
// runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name.
|
|
||||||
//
|
|
||||||
// **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259
|
|
||||||
// §8), so the node's file modes bound what it sees — never the operator's account, which sees the
|
|
||||||
// operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept,
|
|
||||||
// which the controller names for it (secretPlaces), and every answer passes the content rule's secret class
|
|
||||||
// (mesh-sdk's outward, ADR 0310 §4).
|
|
||||||
|
|
||||||
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
|
|
||||||
const ReadsSeat = "node-reads"
|
|
||||||
|
|
||||||
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
|
|
||||||
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
|
|
||||||
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
|
|
||||||
|
|
||||||
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
|
|
||||||
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
|
|
||||||
// every answer it cuts.
|
|
||||||
func readsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
|
|
||||||
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
|
|
||||||
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
|
|
||||||
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"paths": "one absolute path, or several separated by line breaks",
|
|
||||||
"follow": "\"true\": also say where the path leads once every link is followed",
|
|
||||||
}, []string{"paths"}, "follow"),
|
|
||||||
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
|
|
||||||
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
|
|
||||||
"link is followed, and whether that exists.",
|
|
||||||
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
|
|
||||||
Replaces: []string{"readlink", "readlink -f", "realpath"}},
|
|
||||||
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
|
|
||||||
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
|
|
||||||
"is refused by name.",
|
|
||||||
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
|
|
||||||
Replaces: []string{"ls", "ls -la"}},
|
|
||||||
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
|
|
||||||
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
|
|
||||||
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
|
|
||||||
"number said. /proc and /sys are read as far as the holder's account may.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"path": "an absolute path to a file",
|
|
||||||
"from": "the first line to answer, counted from 1 (optional)",
|
|
||||||
"lines": "how many lines to answer (optional)",
|
|
||||||
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
|
|
||||||
}, []string{"path"}),
|
|
||||||
Replaces: []string{"cat", "head", "tail", "less"}},
|
|
||||||
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
|
|
||||||
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
|
|
||||||
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
|
|
||||||
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
|
|
||||||
"secret is withheld. The answer says which bound it met.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"text": "the fixed string to find",
|
|
||||||
"path": "an absolute path to a file or a directory",
|
|
||||||
"depth": "how many directories deep, up to 8 (optional)",
|
|
||||||
"matches": "the most matches to answer, up to 500 (optional)",
|
|
||||||
"case": "\"false\": match whatever the case (optional)",
|
|
||||||
}, []string{"text", "path"}, "case"),
|
|
||||||
Replaces: []string{"grep", "grep -r", "grep -F"}},
|
|
||||||
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
|
|
||||||
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
|
|
||||||
"neither, every account and group. Never a password field.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"name": "an account's name or number (optional)",
|
|
||||||
"group": "a group's name or number (optional)",
|
|
||||||
}, nil),
|
|
||||||
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an
|
|
||||||
// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would
|
|
||||||
// read as the operator's account, which sees the operator's keys and the agent's credentials.
|
|
||||||
func ReadsHolderProblems(m Manifest) []string {
|
|
||||||
if !m.ClaimsSeat(ReadsSeat) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if m.RunsAs == "" {
|
|
||||||
return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+
|
|
||||||
"bounded by that account's file modes, and the machine's runtime runs as the operator's account "+
|
|
||||||
"(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)}
|
|
||||||
}
|
|
||||||
// In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes
|
|
||||||
// are what bound its reads.
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if groups, said := r["groups"]; said {
|
|
||||||
if list, ok := groups.([]any); !ok || len(list) > 0 {
|
|
||||||
return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+
|
|
||||||
"reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310
|
|
||||||
// §5): the directory of every module's own secret, every credential file a module is given and every grant
|
|
||||||
// directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed
|
|
||||||
// secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it,
|
|
||||||
// a directory declared without a path included. Sorted, each once, and a place under another dropped, so the
|
|
||||||
// list says each refusal once.
|
|
||||||
//
|
|
||||||
// **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the
|
|
||||||
// module named: a list that silently shrank would be a refusal the decision promises and nothing does.
|
|
||||||
func (r Resolution) secretPlaces(with Rendering) ([]string, error) {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var problems []string
|
|
||||||
add := func(module, what, path string) {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if path == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
path = filepath.Clean(path)
|
|
||||||
if path == "/" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[path] = true
|
|
||||||
}
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
for name, own := range m.OwnSecrets {
|
|
||||||
add(m.Module, "own secret "+name, own.Path)
|
|
||||||
if strings.HasPrefix(own.Path, "/") {
|
|
||||||
add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, to := range m.SecretRequirements() {
|
|
||||||
for _, f := range m.SecretFiles(to) {
|
|
||||||
add(m.Module, "credential file for "+to, f.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for to, where := range m.Grants {
|
|
||||||
add(m.Module, "grant directory for "+to, where)
|
|
||||||
}
|
|
||||||
add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps)
|
|
||||||
if providesSecret(m) {
|
|
||||||
dirs := dirsFor(m, with)
|
|
||||||
for _, id := range sortedKeys(dirs) {
|
|
||||||
add(m.Module, "directory "+id, dirs[id])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
sort.Strings(problems)
|
|
||||||
return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+
|
|
||||||
"one (novox/hq ADR 0310 §5): %s", ReadsSeat, r.Node, strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
all := make([]string, 0, len(seen))
|
|
||||||
for p := range seen {
|
|
||||||
all = append(all, p)
|
|
||||||
}
|
|
||||||
sort.Strings(all)
|
|
||||||
var out []string
|
|
||||||
for _, p := range all {
|
|
||||||
if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func providesSecret(m Manifest) bool {
|
|
||||||
for _, p := range m.Provides {
|
|
||||||
if p.Name == "secret" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// readsWords is what the holder of node-reads is given beside its own words: the node's secret places.
|
|
||||||
func (r Resolution) readsWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
|
||||||
if !m.ClaimsSeat(ReadsSeat) {
|
|
||||||
return words, nil
|
|
||||||
}
|
|
||||||
places, err := r.secretPlaces(with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(places)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if words == nil {
|
|
||||||
words = map[string]string{}
|
|
||||||
}
|
|
||||||
if _, has := words[ReadsSecretPlaces]; has {
|
|
||||||
return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)",
|
|
||||||
m.Module, ReadsSecretPlaces)
|
|
||||||
}
|
|
||||||
words[ReadsSecretPlaces] = string(body)
|
|
||||||
return words, nil
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a move does to a module's containers (novox/hq ADR 0245).
|
|
||||||
//
|
|
||||||
// A container whose declaration changes is recreated, whatever changed in it: an image, an environment
|
|
||||||
// variable, a health check adopted. A module whose containers are recreated in one send is down while
|
|
||||||
// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks
|
|
||||||
// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a
|
|
||||||
// send says, per module, how many of its containers it recreates and whether any image changed, before
|
|
||||||
// it is sent and in the plan that sent it.
|
|
||||||
|
|
||||||
// Recreation is what moving a module from one build's manifest to another's does to its containers.
|
|
||||||
type Recreation struct {
|
|
||||||
// Containers is how many containers the new build declares.
|
|
||||||
Containers int
|
|
||||||
// Recreated are the ids of the containers whose declaration differs — changed, added or gone —
|
|
||||||
// sorted.
|
|
||||||
Recreated []string
|
|
||||||
// Images are the ids among them whose image changed (or that are added or gone).
|
|
||||||
Images []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreates compares two builds of a module, container by container, by resource id.
|
|
||||||
func Recreates(from, to Manifest) Recreation {
|
|
||||||
before := containersByID(from)
|
|
||||||
after := containersByID(to)
|
|
||||||
var r Recreation
|
|
||||||
r.Containers = len(after)
|
|
||||||
for id, now := range after {
|
|
||||||
was, held := before[id]
|
|
||||||
switch {
|
|
||||||
case !held:
|
|
||||||
r.Recreated = append(r.Recreated, id)
|
|
||||||
r.Images = append(r.Images, id)
|
|
||||||
case !sameDeclaration(was, now):
|
|
||||||
r.Recreated = append(r.Recreated, id)
|
|
||||||
if !sameDeclaration(was["image"], now["image"]) {
|
|
||||||
r.Images = append(r.Images, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id := range before {
|
|
||||||
if _, kept := after[id]; !kept {
|
|
||||||
r.Recreated = append(r.Recreated, id)
|
|
||||||
r.Images = append(r.Images, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(r.Recreated)
|
|
||||||
sort.Strings(r.Images)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpecOnly is whether the move recreates containers without any new image: a change to how they are
|
|
||||||
// declared only — a health check adopted, a variable — which a person may well not expect to interrupt.
|
|
||||||
func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 }
|
|
||||||
|
|
||||||
// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing.
|
|
||||||
func (r Recreation) Say(module string) string {
|
|
||||||
if len(r.Recreated) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
what := "with a new image"
|
|
||||||
switch {
|
|
||||||
case r.SpecOnly():
|
|
||||||
what = "no new image, only their declaration"
|
|
||||||
case len(r.Images) < len(r.Recreated):
|
|
||||||
what = fmt.Sprintf("%d with a new image", len(r.Images))
|
|
||||||
}
|
|
||||||
whole := ""
|
|
||||||
if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers {
|
|
||||||
whole = ", every one at once: its service is interrupted until they are up again"
|
|
||||||
} else if len(r.Recreated) > 1 {
|
|
||||||
whole = ", at once: what they serve is interrupted until they are up again"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers,
|
|
||||||
what, strings.Join(r.Recreated, ", "), whole)
|
|
||||||
}
|
|
||||||
|
|
||||||
func containersByID(m Manifest) map[string]map[string]any {
|
|
||||||
out := map[string]map[string]any{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if t, _ := r["type"].(string); t != "container" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, _ := r["id"].(string)
|
|
||||||
out[id] = r
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a
|
|
||||||
// float are one value.
|
|
||||||
func sameDeclaration(a, b any) bool {
|
|
||||||
ra, errA := json.Marshal(a)
|
|
||||||
rb, errB := json.Marshal(b)
|
|
||||||
if errA != nil || errB != nil {
|
|
||||||
return reflect.DeepEqual(a, b)
|
|
||||||
}
|
|
||||||
var na, nb any
|
|
||||||
_ = json.Unmarshal(ra, &na)
|
|
||||||
_ = json.Unmarshal(rb, &nb)
|
|
||||||
return reflect.DeepEqual(na, nb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// What a move under a module's own `record` may do without a person (novox/hq ADR 0377).
|
|
||||||
//
|
|
||||||
// A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers
|
|
||||||
// recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That
|
|
||||||
// hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools
|
|
||||||
// bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a
|
|
||||||
// person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches
|
|
||||||
// data still waits.
|
|
||||||
|
|
||||||
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
|
|
||||||
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
|
|
||||||
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
|
|
||||||
// lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides,
|
|
||||||
// requires or contributes; and, for a module that declares data of its own, no change to a long-running process.
|
|
||||||
// Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate
|
|
||||||
// nothing — its tool list — may differ.
|
|
||||||
func Holds(from, to Manifest) string {
|
|
||||||
module := to.Module
|
|
||||||
if module == "" {
|
|
||||||
module = from.Module
|
|
||||||
}
|
|
||||||
if said := Recreates(from, to).Say(module); said != "" {
|
|
||||||
return said
|
|
||||||
}
|
|
||||||
if !sameDeclaration(from.Data, to.Data) {
|
|
||||||
return "it changes " + module + "'s data declaration"
|
|
||||||
}
|
|
||||||
for _, f := range []struct {
|
|
||||||
what string
|
|
||||||
was, s any
|
|
||||||
}{
|
|
||||||
{"what it provides", from.Provides, to.Provides},
|
|
||||||
{"what it requires", from.Requires, to.Requires},
|
|
||||||
{"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires},
|
|
||||||
{"what it contributes to a provider", from.Contributes, to.Contributes},
|
|
||||||
{"what it contributes to a provider", from.ContributesMany, to.ContributesMany},
|
|
||||||
// A setting is filled into the files the module writes: a changed default changes what a container may
|
|
||||||
// mount without the file's own declaration changing.
|
|
||||||
{"its settings, which the files it writes are filled from", from.Settings, to.Settings},
|
|
||||||
} {
|
|
||||||
if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) {
|
|
||||||
return "it changes " + f.what
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mounted := append(mountSources(from), mountSources(to)...)
|
|
||||||
data := append(dataPaths(from), dataPaths(to)...)
|
|
||||||
keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0)
|
|
||||||
before, after := resourcesByID(from), resourcesByID(to)
|
|
||||||
var changed []string
|
|
||||||
for id, r := range after {
|
|
||||||
if was, held := before[id]; !held || !sameDeclaration(was, r) {
|
|
||||||
changed = append(changed, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id := range before {
|
|
||||||
if _, kept := after[id]; !kept {
|
|
||||||
changed = append(changed, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(changed)
|
|
||||||
dirs := absoluteDirectories(from, to)
|
|
||||||
for _, key := range changed {
|
|
||||||
for _, r := range []map[string]any{before[key], after[key]} {
|
|
||||||
if r == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"])
|
|
||||||
switch kind {
|
|
||||||
case "file", "directory":
|
|
||||||
for _, p := range pathsOf(r, dirs) {
|
|
||||||
for _, m := range mounted {
|
|
||||||
if pathsMeet(p, expandDir(m.source, dirs)) {
|
|
||||||
return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, d := range data {
|
|
||||||
if pathsMeet(p, expandDir(d.path, dirs)) {
|
|
||||||
return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "process", "service":
|
|
||||||
if keepsData {
|
|
||||||
return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Anything that is not a file or directory (judged above by whether a container mounts it
|
|
||||||
// or it holds data) always holds: an archive, a package, a user, a network, or any type
|
|
||||||
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
|
|
||||||
// fields that recreate nothing).
|
|
||||||
return fmt.Sprintf("it changes the %s %s", kind, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type mountSource struct{ container, source string }
|
|
||||||
|
|
||||||
// mountSources is every path a container of the module reads from its machine: its volumes' sources and its
|
|
||||||
// environment files.
|
|
||||||
func mountSources(m Manifest) []mountSource {
|
|
||||||
var out []mountSource
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "container" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
vols, _ := r["volumes"].([]any)
|
|
||||||
for _, v := range vols {
|
|
||||||
s, _ := v.(string)
|
|
||||||
if mount := volumeMount.FindStringSubmatch(s); mount != nil {
|
|
||||||
out = append(out, mountSource{id, mount[1]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch files := r["env-file"].(type) {
|
|
||||||
case string:
|
|
||||||
out = append(out, mountSource{id, files})
|
|
||||||
case []any:
|
|
||||||
for _, f := range files {
|
|
||||||
if s, ok := f.(string); ok {
|
|
||||||
out = append(out, mountSource{id, s})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
type dataPath struct{ id, path string }
|
|
||||||
|
|
||||||
func dataPaths(m Manifest) []dataPath {
|
|
||||||
if m.Data == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []dataPath
|
|
||||||
for _, it := range m.Data.Own {
|
|
||||||
out = append(out, dataPath{it.ID, it.Path})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func resourcesByID(m Manifest) map[string]map[string]any {
|
|
||||||
out := map[string]map[string]any{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// absoluteDirectories is, per directory id, the absolute path a directory of either build states.
|
|
||||||
func absoluteDirectories(ms ...Manifest) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, m := range ms {
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
|
||||||
out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:<id>}`.
|
|
||||||
func pathsOf(r map[string]any, dirs map[string]string) []string {
|
|
||||||
var out []string
|
|
||||||
if p, ok := r["path"].(string); ok && p != "" {
|
|
||||||
out = append(out, expandDir(p, dirs))
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// expandDir writes a leading `${dir:<id>}` as the directory's absolute path when one is stated, so a path named
|
|
||||||
// either way is one path.
|
|
||||||
func expandDir(p string, dirs map[string]string) string {
|
|
||||||
p = strings.TrimRight(p, "/")
|
|
||||||
if ref := dirPlain.FindStringSubmatch(p); ref != nil {
|
|
||||||
if abs, ok := dirs[ref[1]]; ok {
|
|
||||||
return abs
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(p, "${dir:") {
|
|
||||||
end := strings.Index(p, "}")
|
|
||||||
if abs, ok := dirs[p[len("${dir:"):end]]; ok {
|
|
||||||
return abs + p[end+1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathsMeet is whether one path is the other or lies within it.
|
|
||||||
func pathsMeet(a, b string) bool {
|
|
||||||
if a == "" || b == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out.
|
|
||||||
func emptyAsNil(v any) any {
|
|
||||||
rv := reflect.ValueOf(v)
|
|
||||||
switch rv.Kind() {
|
|
||||||
case reflect.Slice, reflect.Map:
|
|
||||||
if rv.Len() == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case reflect.Ptr:
|
|
||||||
if rv.IsNil() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,287 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"text/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What only the mesh knows, written where a module asks for it — in the module's own format.
|
|
||||||
//
|
|
||||||
// **The graph is the control plane's; the format is the module's.** The mesh knows which machines
|
|
||||||
// exist, what they are called and where they are. Turning that into a hosts file, a resolver's
|
|
||||||
// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business
|
|
||||||
// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never
|
|
||||||
// learns what the file means.
|
|
||||||
//
|
|
||||||
// This used to be a closed list of fact names, each with its format written in Go here — a hosts
|
|
||||||
// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the
|
|
||||||
// consumer's configuration language. Now the data is the mesh's and the format is a template the
|
|
||||||
// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render
|
|
||||||
// the same way any module's would, and the control plane holds no format at all.
|
|
||||||
//
|
|
||||||
// It replaced three modules that existed only because computed output needed somewhere to live —
|
|
||||||
// they ran no software, could not be swapped for anything, and appeared in the graph as modules
|
|
||||||
// while being a data channel wearing a costume (novox/hq ADR 0040).
|
|
||||||
|
|
||||||
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
|
|
||||||
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
|
|
||||||
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names` and
|
|
||||||
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not
|
|
||||||
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq
|
|
||||||
// issue 111 distinction is kept as two fields so the templates that range either keep rendering.
|
|
||||||
type RosterFile struct {
|
|
||||||
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
|
|
||||||
// than discovered as a daemon that reads nothing.
|
|
||||||
Path string `json:"path"`
|
|
||||||
// Template is the module's format, a Go text/template over the rosterView. It is the module's,
|
|
||||||
// not the mesh's: the mesh renders it and does not read it.
|
|
||||||
Template string `json:"template"`
|
|
||||||
// Shared is whether the file the fact goes to belongs to the machine rather than the mesh. When
|
|
||||||
// it does, the mesh owns only a marked region of it and keeps the rest byte for byte (novox/hq
|
|
||||||
// issue 128) — a hosts file is shared, since the distribution's `localhost`, the operator's own
|
|
||||||
// lines and other tools' blocks live there too; a resolver's zones file is not, the mesh owns it
|
|
||||||
// whole. A property of the fact, not of the path: the format determines whether the file is
|
|
||||||
// wholly the mesh's, not where a module happened to ask for it.
|
|
||||||
Shared bool `json:"shared,omitempty"`
|
|
||||||
// Home places the file under this node's operator-account home and chowns it to that account,
|
|
||||||
// rather than at an absolute system path (novox/hq to-be 29). Then Path is home-relative
|
|
||||||
// (`.ssh/config.d/mesh`), resolved against the account's home on the node it is composed for; a
|
|
||||||
// node with no operator account gets no such file. This is how the ssh-client config — every
|
|
||||||
// other node's Host block — is written into a person's home rather than into /etc.
|
|
||||||
Home bool `json:"home,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field
|
|
||||||
// the mesh does not compute fails to render here, not on a machine.
|
|
||||||
type rosterView struct {
|
|
||||||
Node string
|
|
||||||
Suffix string
|
|
||||||
Names []rosterEntry
|
|
||||||
Machines []rosterEntry
|
|
||||||
// Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq
|
|
||||||
// ADR 0199) — what the mesh's resolver forwards. Ordered by zone.
|
|
||||||
Zones []rosterZone
|
|
||||||
// Holders is the machines holding each replicated mesh seat, by seat (novox/hq ADR 0223) — what
|
|
||||||
// a machine's resolver file lists for `mesh-dns-resolver`. **This machine first when it is one
|
|
||||||
// of them**, then the rest by name: the nearest holder is asked first, and two renderings of
|
|
||||||
// one mesh on one machine are one file. A template reads one seat's with
|
|
||||||
// `index .Holders "<seat>"`; a seat nobody holds ranges over nothing.
|
|
||||||
Holders map[string][]rosterEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
// rosterZone is one zone as a template sees it: the zone, and the address and port answering it.
|
|
||||||
type rosterZone struct {
|
|
||||||
Zone string
|
|
||||||
Address string
|
|
||||||
Port int
|
|
||||||
}
|
|
||||||
|
|
||||||
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
|
|
||||||
// and the operator account to log into it as (novox/hq to-be 29) — empty when none is known, so an
|
|
||||||
// ssh Host block template can omit the User line for a machine nobody has an account on.
|
|
||||||
type rosterEntry struct {
|
|
||||||
Name string
|
|
||||||
FQDN string
|
|
||||||
Address string
|
|
||||||
Account string
|
|
||||||
}
|
|
||||||
|
|
||||||
// FactsInto renders the roster files a module asked for, as files it will be given.
|
|
||||||
//
|
|
||||||
// The module owns everything after the file exists: loading it, restarting on it, what a resolver
|
|
||||||
// or a client does with it. This only puts it there. `every` is every name the mesh serves;
|
|
||||||
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
|
|
||||||
// are given and the template chooses.
|
|
||||||
func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
|
|
||||||
return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them.
|
|
||||||
func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string,
|
|
||||||
zones []ZoneAt) ([]map[string]any, error) {
|
|
||||||
return FactsFrom(m, r, Rendering{Names: every, Machines: machines, Accounts: accounts, Suffix: suffix,
|
|
||||||
Zones: zones})
|
|
||||||
}
|
|
||||||
|
|
||||||
// FactsFrom renders the roster files a module asked for from everything the mesh composed for this
|
|
||||||
// machine: its machines, zones and the holders of each replicated seat.
|
|
||||||
func FactsFrom(m Manifest, r Resolution, with Rendering) ([]map[string]any, error) {
|
|
||||||
if len(m.Facts) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
names := sortedFacts(m.Facts)
|
|
||||||
|
|
||||||
view := rosterView{
|
|
||||||
Node: r.Node,
|
|
||||||
Suffix: strings.TrimPrefix(suffixOr(with.Suffix), "."),
|
|
||||||
Names: entriesFrom(with.Names, with.Accounts, with.Suffix),
|
|
||||||
Machines: entriesFrom(with.Machines, with.Accounts, with.Suffix),
|
|
||||||
Zones: zonesFrom(with.Zones),
|
|
||||||
Holders: holdersFrom(with.Holders, with.Accounts, with.Suffix, r.Node),
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]map[string]any, 0, len(names))
|
|
||||||
for _, name := range names {
|
|
||||||
fact := m.Facts[name]
|
|
||||||
content, err := renderRoster(fact.Template, view)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err)
|
|
||||||
}
|
|
||||||
// Where the file goes: under the operator's home and chowned to it (a home fact), or at the
|
|
||||||
// absolute system path it names. A home fact on a machine with no operator account cannot be
|
|
||||||
// placed, and is left out rather than written to nowhere (novox/hq to-be 29).
|
|
||||||
path := fact.Path
|
|
||||||
var owner string
|
|
||||||
if fact.Home {
|
|
||||||
if r.Account == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path = accountHomeOf(r.Account, r.AccountHome) + "/" + strings.TrimLeft(fact.Path, "/")
|
|
||||||
owner = r.Account
|
|
||||||
} else if !strings.HasPrefix(fact.Path, "/") {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path)
|
|
||||||
}
|
|
||||||
file := map[string]any{
|
|
||||||
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
if owner != "" {
|
|
||||||
file["owner"] = owner
|
|
||||||
}
|
|
||||||
if fact.Shared {
|
|
||||||
// The host owns only the lines between `# BEGIN mesh <id>` and `# END mesh <id>` and
|
|
||||||
// keeps the rest of the file byte for byte; undeclared, the region goes and nothing else
|
|
||||||
// does (novox/hq issue 128). Every node on the private network receives this, so every
|
|
||||||
// node's host — the controller's own machine included — must be block-aware before a
|
|
||||||
// controller emitting it is rolled out: the order ADR 0102 set for `into: json`.
|
|
||||||
file["into"] = "block"
|
|
||||||
}
|
|
||||||
out = append(out, file)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderRoster runs a module's template over the roster. A template that will not parse, or reads
|
|
||||||
// a field the mesh does not have, is an error here — where the manifest is — rather than an empty
|
|
||||||
// file on a machine.
|
|
||||||
func renderRoster(tmpl string, view rosterView) (string, error) {
|
|
||||||
t, err := template.New("roster").Option("missingkey=error").Parse(tmpl)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var b bytes.Buffer
|
|
||||||
if err := t.Execute(&b, view); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return b.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// entriesFrom is a name→address map as sorted roster entries.
|
|
||||||
//
|
|
||||||
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
|
|
||||||
// and does not yet know where it is, which is the ordinary state between adding a machine and it
|
|
||||||
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
|
|
||||||
// that hangs; leaving it out fails at once and says the name is unknown.
|
|
||||||
func entriesFrom(addresses, accounts map[string]string, suffix string) []rosterEntry {
|
|
||||||
out := make([]rosterEntry, 0, len(addresses))
|
|
||||||
for _, name := range sortedNames(addresses) {
|
|
||||||
if routed(name, suffix) {
|
|
||||||
// A routed name is already a full name under a public domain, and it has no mesh
|
|
||||||
// form: appending the suffix made `<name>.<suffix>`, which every machine's hosts file
|
|
||||||
// carried and nothing served (novox/hq issue 157). It is published as itself, once.
|
|
||||||
out = append(out, rosterEntry{Name: name, FQDN: name, Address: addresses[name], Account: accounts[name]})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
internal, bare := meshName(name, suffix)
|
|
||||||
// The account is looked up by whichever key the caller keys accounts on — the internal name
|
|
||||||
// or the bare one — so a template gets the right login however the maps were built.
|
|
||||||
account := accounts[name]
|
|
||||||
if account == "" {
|
|
||||||
account = accounts[bare]
|
|
||||||
}
|
|
||||||
out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name], Account: account})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// meshName is a machine's internal name and its bare one, from either. The control plane keys
|
|
||||||
// the names it hands a resolution by the internal name (`homer.internal`), the same map a
|
|
||||||
// container gets as its hosts; a caller that keys by the bare name gets the same answer. The
|
|
||||||
// suffix is the one the control plane composed those names with, handed down rather than written
|
|
||||||
// here a second time — the alternative was `homer.internal.internal` on every machine.
|
|
||||||
func meshName(name, suffix string) (internal, bare string) {
|
|
||||||
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
|
|
||||||
if strings.HasSuffix(name, dotted) {
|
|
||||||
return name, strings.TrimSuffix(name, dotted)
|
|
||||||
}
|
|
||||||
return name + dotted, name
|
|
||||||
}
|
|
||||||
|
|
||||||
// routed says whether a name the mesh serves is a routed public name rather than a machine's: it
|
|
||||||
// carries a domain of its own and not the mesh's suffix. A machine's name is bare (`homer`) or
|
|
||||||
// internal (`homer.internal`); anything else with a dot in it was composed under a public domain.
|
|
||||||
func routed(name, suffix string) bool {
|
|
||||||
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
|
|
||||||
return strings.Contains(name, ".") && !strings.HasSuffix(name, dotted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
|
|
||||||
// The one place the default is written, so a fact and a name cannot disagree about it.
|
|
||||||
func suffixOr(suffix string) string {
|
|
||||||
if suffix == "" {
|
|
||||||
return "internal"
|
|
||||||
}
|
|
||||||
return suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedNames(addresses map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(addresses))
|
|
||||||
for name, at := range addresses {
|
|
||||||
// A machine the mesh cannot place is left out rather than named at nothing.
|
|
||||||
if at == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file.
|
|
||||||
func zonesFrom(zones []ZoneAt) []rosterZone {
|
|
||||||
out := make([]rosterZone, 0, len(zones))
|
|
||||||
for _, z := range zones {
|
|
||||||
out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// holdersFrom is each replicated seat's holders as a template ranges them: this machine first when
|
|
||||||
// it holds the seat, then the others by name (novox/hq ADR 0223). A machine with no address is left
|
|
||||||
// out, as everywhere in the roster — a resolver named at nothing is a lookup that hangs.
|
|
||||||
func holdersFrom(holders map[string]map[string]string, accounts map[string]string, suffix, node string) map[string][]rosterEntry {
|
|
||||||
out := make(map[string][]rosterEntry, len(holders))
|
|
||||||
for seat, at := range holders {
|
|
||||||
entries := entriesFrom(at, accounts, suffix)
|
|
||||||
sort.SliceStable(entries, func(i, j int) bool {
|
|
||||||
return entries[i].Name == node && entries[j].Name != node
|
|
||||||
})
|
|
||||||
out[seat] = entries
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortedFacts is a module's fact names in order, so what is said about them is said the same way twice.
|
|
||||||
func sortedFacts(facts map[string]RosterFile) []string {
|
|
||||||
out := make([]string, 0, len(facts))
|
|
||||||
for name := range facts {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The node's tool runtime, as the catalogue knows it (novox/hq ADR 0175, to-be 38).
|
|
||||||
//
|
|
||||||
// **One module is the runtime.** Where it is assigned, one process per machine serves every assigned
|
|
||||||
// module's tools and every held seat's verbs, on the host side, from the bundles each module's build
|
|
||||||
// produced — and no module needs a container to reach the bus with its tools. The name is a constant
|
|
||||||
// rather than a manifest field because a rule turns on it: the composer places the runtime's process
|
|
||||||
// where this module is, and registration refuses the old pattern once this module exists.
|
|
||||||
|
|
||||||
// RuntimeModule is the module that is the node's tool runtime. Mirrored in the broker package,
|
|
||||||
// which composes a principal of its own for it; the agreement test there holds the two to one string.
|
|
||||||
const RuntimeModule = "node-tools"
|
|
||||||
|
|
||||||
// BundleRoot is where a machine keeps the tools bundles the mesh delivers to it: under the mesh's
|
|
||||||
// own directory, beside the daemons the host unpacks there, and never where a package manager also
|
|
||||||
// writes. One directory per module, one per bundle beneath it, at a path that does not move with
|
|
||||||
// the version — so the runtime's process names each entrypoint once and is restarted, not
|
|
||||||
// recomposed, when a bundle changes.
|
|
||||||
const BundleRoot = "/var/lib/mesh/bundles"
|
|
||||||
|
|
||||||
// BundleID names the archive resource that delivers one of a module's bundles; prefixed with the
|
|
||||||
// module like every resource of its own.
|
|
||||||
func BundleID(bundle string) string { return "bundle-" + bundle }
|
|
||||||
|
|
||||||
// BundlePath is where one module's bundle is unpacked on a machine.
|
|
||||||
func BundlePath(module, bundle string) string { return BundleRoot + "/" + module + "/" + bundle }
|
|
||||||
|
|
||||||
// runtimeHere says whether this node's set includes the runtime module, which is what decides
|
|
||||||
// whether anything about tools changes on the machine (to-be 38 WP2): until the runtime is assigned,
|
|
||||||
// a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads
|
|
||||||
// is bytes nobody reads.
|
|
||||||
func (r Resolution) runtimeHere() bool {
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
if m.Module == RuntimeModule {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something
|
|
||||||
// from — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings its
|
|
||||||
// tools as a bundle, delivered by the host like any artifact, never an image). A bundle it loads
|
|
||||||
// nothing from is run rather than loaded: a daemon, a step, the runtime itself — delivered by the
|
|
||||||
// process that runs it, and not again here.
|
|
||||||
//
|
|
||||||
// The source is the kept reference; the per-resource pass that follows routes it through the
|
|
||||||
// artifact store as this network reaches it now, as it does every image and archive the mesh built.
|
|
||||||
func bundleArchives(m Manifest) []map[string]any {
|
|
||||||
var out []map[string]any
|
|
||||||
for _, b := range m.Bundles {
|
|
||||||
if len(b.Loads) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, map[string]any{
|
|
||||||
"id": BundleID(b.Name), "type": "archive",
|
|
||||||
"source": b.Source, "digest": b.Digest,
|
|
||||||
"path": BundlePath(m.Module, b.Name),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeProcessID names the one process the mesh composes for a machine's runtime; prefixed with
|
|
||||||
// the runtime module like a resource of its own, because that module is what the host sees it as.
|
|
||||||
func RuntimeProcessID() string { return "runtime" }
|
|
||||||
|
|
||||||
// RuntimeToolModules is the variable the runtime reads the modules it serves from: one
|
|
||||||
// `<module>=<entrypoint>` per file it loads, comma-separated — several entries may name one module.
|
|
||||||
// RuntimeBrokerFile is where it reads the node's credential; RuntimeOperatorAccount and
|
|
||||||
// RuntimeOperatorHome are the machine's operator account and home, handed to every tool's
|
|
||||||
// environment (to-be 38 WP1), and absent on a machine with no account.
|
|
||||||
const (
|
|
||||||
RuntimeToolModules = "MESH_TOOL_MODULES"
|
|
||||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
|
||||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
|
||||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
|
||||||
// RuntimeAgentAccount and RuntimeAgentHome are the account agents run as on the machine and its home
|
|
||||||
// (novox/hq ADR 0266): the agent account where the node names one, the operator account otherwise.
|
|
||||||
// The agent's module writes the agent's home from them; absent where neither account is known.
|
|
||||||
RuntimeAgentAccount = "MESH_AGENT_ACCOUNT"
|
|
||||||
RuntimeAgentHome = "MESH_AGENT_HOME"
|
|
||||||
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
|
||||||
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
|
||||||
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
|
||||||
// change to any module's words changes the process and restarts it.
|
|
||||||
RuntimeToolEnv = "MESH_TOOL_ENV"
|
|
||||||
)
|
|
||||||
|
|
||||||
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
|
||||||
// bundle's entrypoint after it. The one thing the composer takes from a language, and said here
|
|
||||||
// rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3).
|
|
||||||
func interpreterFor(language string) (string, error) {
|
|
||||||
switch language {
|
|
||||||
case "typescript":
|
|
||||||
return "node", nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"%s is written in %q, and the mesh knows no interpreter to run a %q bundle with",
|
|
||||||
RuntimeModule, language, language)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175,
|
|
||||||
// to-be 38 WP2.3): the runtime module's own bundle, run by its language's interpreter, told which
|
|
||||||
// modules it serves and from which files, where its credential is, and who the machine's operator
|
|
||||||
// is — and restarted when any bundle it loads or the credential it holds changes.
|
|
||||||
//
|
|
||||||
// Composed from the placed manifests, so the credential's path is where this node puts it. The
|
|
||||||
// runtime runs as the operator's account when the machine has one, which is what lets a tool that
|
|
||||||
// needs root escalate as the operator would (ADR 0175 §4); on a machine with no account it runs as
|
|
||||||
// root, and the two operator words are not set.
|
|
||||||
func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|
||||||
var runtime *Manifest
|
|
||||||
for i := range r.Modules {
|
|
||||||
if r.Modules[i].Module == RuntimeModule {
|
|
||||||
runtime = &r.Modules[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runtime == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if len(runtime.Bundles) != 1 {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+
|
|
||||||
"the mesh runs, so the module declares exactly one (novox/hq to-be 38)",
|
|
||||||
RuntimeModule, r.Node, len(runtime.Bundles))
|
|
||||||
}
|
|
||||||
bundle := runtime.Bundles[0]
|
|
||||||
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
|
|
||||||
// unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint.
|
|
||||||
var run []any
|
|
||||||
if bundle.Binary != "" {
|
|
||||||
run = []any{"./" + bundle.Binary}
|
|
||||||
} else {
|
|
||||||
if len(bundle.Entrypoints) != 1 {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
|
||||||
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
|
||||||
}
|
|
||||||
interpreter, err := interpreterFor(bundle.Language)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
run = []any{interpreter, bundle.Entrypoints[0]}
|
|
||||||
}
|
|
||||||
credential, declared := runtime.OwnSecrets["broker"]
|
|
||||||
if !declared {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s declares no own secret named broker, and the node's credential is delivered there: "+
|
|
||||||
"a module that speaks on the bus declares \"own-secrets\": {\"broker\": <path>}",
|
|
||||||
RuntimeModule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// What it serves, and from which files: every module on this machine that composes here, in
|
|
||||||
// name order, each bundle it loads from in the order the manifest gave. A module left out of
|
|
||||||
// the declaration — a filter on an adopted machine — is left out of this too, or the runtime
|
|
||||||
// would be told to load files that were never delivered.
|
|
||||||
var served []string
|
|
||||||
var restartOn []string
|
|
||||||
given := map[string]map[string]string{}
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
if with.Adopted && m.Filtering != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.RunsAs != "" {
|
|
||||||
// Served by a runtime of its own, on its own account (ownRuntimes): never the machine's.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
words, err := bundleWords(m, with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// The holder of node-files is told the same, and the paths modules own here (novox/hq ADR 0394 §4).
|
|
||||||
if words, err = r.filesWords(m, words, with); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(words) > 0 {
|
|
||||||
given[m.Module] = words
|
|
||||||
}
|
|
||||||
for _, b := range m.Bundles {
|
|
||||||
if len(b.Loads) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, load := range b.Loads {
|
|
||||||
// What the runtime starts: the launcher the build wrote beside the entrypoint, where
|
|
||||||
// it wrote one (ADR 0193); the entrypoint itself for a build from before them.
|
|
||||||
if launcher, has := b.Launchers[load]; has {
|
|
||||||
load = launcher
|
|
||||||
}
|
|
||||||
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
|
||||||
}
|
|
||||||
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(served)
|
|
||||||
restartOn = append(restartOn, RuntimeModule+"."+NeedID("broker"))
|
|
||||||
sort.Strings(restartOn)
|
|
||||||
|
|
||||||
env := map[string]string{
|
|
||||||
RuntimeToolModules: strings.Join(served, ","),
|
|
||||||
RuntimeBrokerFile: credential.Path,
|
|
||||||
}
|
|
||||||
if len(given) > 0 {
|
|
||||||
// Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit.
|
|
||||||
body, err := json.Marshal(given)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
env[RuntimeToolEnv] = string(body)
|
|
||||||
}
|
|
||||||
process := map[string]any{
|
|
||||||
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
|
|
||||||
"source": bundle.Source, "digest": bundle.Digest,
|
|
||||||
"run": run,
|
|
||||||
"env": env,
|
|
||||||
"restart-on": toAny(restartOn),
|
|
||||||
}
|
|
||||||
if r.Account != "" {
|
|
||||||
env[RuntimeOperatorAccount] = r.Account
|
|
||||||
env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome)
|
|
||||||
process["user"] = r.Account
|
|
||||||
}
|
|
||||||
if agent, home := r.agentAccount(); agent != "" {
|
|
||||||
env[RuntimeAgentAccount] = agent
|
|
||||||
env[RuntimeAgentHome] = home
|
|
||||||
}
|
|
||||||
// Routed through the artifact store as this network reaches it now, like everything the mesh
|
|
||||||
// built; refused with the same words when there is no store to route through.
|
|
||||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return process, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnRuntimeID names the process a module of its own account is served by (novox/hq ADR 0259 §8).
|
|
||||||
func OwnRuntimeID() string { return "own-runtime" }
|
|
||||||
|
|
||||||
// ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each
|
|
||||||
// the machine's runtime program — the same build, run from the same source — serving that one module alone,
|
|
||||||
// as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs
|
|
||||||
// as the operator's account and carries every module on the machine.
|
|
||||||
func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
|
||||||
var own []Manifest
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
if m.RunsAs != "" && !(with.Adopted && m.Filtering != nil) {
|
|
||||||
own = append(own, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(own) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var runtime *Manifest
|
|
||||||
for i := range r.Modules {
|
|
||||||
if r.Modules[i].Module == RuntimeModule {
|
|
||||||
runtime = &r.Modules[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" {
|
|
||||||
return nil, fmt.Errorf("%s runs as its own account in a runtime of its own, and %s is not here to run it "+
|
|
||||||
"from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, RuntimeModule, RuntimeModule, r.Node)
|
|
||||||
}
|
|
||||||
program := runtime.Bundles[0]
|
|
||||||
var out []map[string]any
|
|
||||||
for _, m := range own {
|
|
||||||
// Never the node's operator account, nor the account agents run as there (the review of 2026-10-09):
|
|
||||||
// either would hand what it holds back to the very accounts it is kept from.
|
|
||||||
switch {
|
|
||||||
case r.Account != "" && m.RunsAs == r.Account:
|
|
||||||
return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+
|
|
||||||
"runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
|
||||||
case r.AgentAccount != "" && m.RunsAs == r.AgentAccount:
|
|
||||||
return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+
|
|
||||||
"never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node)
|
|
||||||
}
|
|
||||||
credential, declared := m.OwnSecrets["broker"]
|
|
||||||
if !declared {
|
|
||||||
return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module)
|
|
||||||
}
|
|
||||||
var served, restartOn []string
|
|
||||||
for _, b := range m.Bundles {
|
|
||||||
for _, load := range b.Loads {
|
|
||||||
if launcher, has := b.Launchers[load]; has {
|
|
||||||
load = launcher
|
|
||||||
}
|
|
||||||
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
|
||||||
}
|
|
||||||
if len(b.Loads) > 0 {
|
|
||||||
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(served) == 0 {
|
|
||||||
return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module)
|
|
||||||
}
|
|
||||||
sort.Strings(served)
|
|
||||||
restartOn = append(restartOn, m.Module+"."+NeedID("broker"))
|
|
||||||
sort.Strings(restartOn)
|
|
||||||
env := map[string]string{RuntimeToolModules: strings.Join(served, ","), RuntimeBrokerFile: credential.Path}
|
|
||||||
words, err := bundleWords(m, with)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5).
|
|
||||||
if words, err = r.readsWords(m, words, with); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(words) > 0 {
|
|
||||||
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
env[RuntimeToolEnv] = string(body)
|
|
||||||
}
|
|
||||||
process := map[string]any{
|
|
||||||
"id": m.Module + "." + OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime",
|
|
||||||
"source": program.Source, "digest": program.Digest,
|
|
||||||
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
|
|
||||||
"user": m.RunsAs,
|
|
||||||
}
|
|
||||||
// How it is ready, as the module declared it (novox/hq ADR 0366 §1), in the node-engine's words.
|
|
||||||
if _, has := OwnRuntimeHealth(m); has {
|
|
||||||
process[HealthField] = m.RuntimeHealth
|
|
||||||
healthInto(process, m, with)
|
|
||||||
}
|
|
||||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, process)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAny(in []string) []any {
|
|
||||||
out := make([]any, 0, len(in))
|
|
||||||
for _, s := range in {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was
|
|
||||||
// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image
|
|
||||||
// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175).
|
|
||||||
const (
|
|
||||||
RuntimeImageModule = "mesh-tools"
|
|
||||||
RuntimeImageArtifact = "runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools
|
|
||||||
// are served from a container built on the tool runtime's image — or nothing when it is not. Judged
|
|
||||||
// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood
|
|
||||||
// on when it is a built one, because a resolved manifest carries no build. The gate itself is
|
|
||||||
// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused.
|
|
||||||
//
|
|
||||||
// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container
|
|
||||||
// (a module's service may well be one); building on the runtime's image (a service written against
|
|
||||||
// the SDK may). All three is a container whose purpose is tools, which the runtime now serves.
|
|
||||||
func ToolContainerOnTheRuntime(m Manifest, against []string) string {
|
|
||||||
if len(m.Tools) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
container := false
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "container" {
|
|
||||||
container = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !container {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
onTheRuntime := false
|
|
||||||
if m.Build != nil {
|
|
||||||
for _, on := range m.Build.On {
|
|
||||||
if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact {
|
|
||||||
onTheRuntime = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, ref := range against {
|
|
||||||
path, kept := InArtifactStore(Recorded(ref))
|
|
||||||
if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") {
|
|
||||||
onTheRuntime = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !onTheRuntime {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"%s declares tools and a container built on %s's %s image — a container whose purpose is "+
|
|
||||||
"serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+
|
|
||||||
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
|
||||||
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
|
|
||||||
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
|
|
||||||
// directories and its ${port:…} to the port this machine gave it — the same resolution a
|
|
||||||
// container's environment gets. Two bundles of one module naming one word differently is refused:
|
|
||||||
// the runtime hands a module's words to all its bundles.
|
|
||||||
func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
|
|
||||||
var out map[string]string
|
|
||||||
dirs := dirsFor(m, with)
|
|
||||||
for _, b := range m.Bundles {
|
|
||||||
if len(b.Loads) == 0 || len(b.Env) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, word := range sortedKeys(b.Env) {
|
|
||||||
value, err := dirFill(b.Env[word], dirs, m.Module)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
out = map[string]string{}
|
|
||||||
}
|
|
||||||
if was, had := out[word]; had && was != value {
|
|
||||||
return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+
|
|
||||||
"handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value)
|
|
||||||
}
|
|
||||||
out[word] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq
|
|
||||||
// ADR 0192): every file and directory of the module whose path a word names, or that holds one,
|
|
||||||
// is owned by the account — a tool reads its configuration and its secret as the account, and a
|
|
||||||
// root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already:
|
|
||||||
// a module that named one knew why. Nothing on a machine with no account, where the runtime is root.
|
|
||||||
//
|
|
||||||
// It answers the files a word names exactly: what a tool reads, whose change the runtime must be
|
|
||||||
// restarted for, as the container the tools came from was restarted when its configuration changed.
|
|
||||||
func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string {
|
|
||||||
var named []string
|
|
||||||
if len(words) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, resource := range out {
|
|
||||||
module := owner[fmt.Sprint(resource["id"])]
|
|
||||||
mine := words[module]
|
|
||||||
if len(mine) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kind := fmt.Sprint(resource["type"])
|
|
||||||
if kind != "file" && kind != "directory" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path, _ := resource["path"].(string)
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, value := range mine {
|
|
||||||
if kind == "file" && value == path {
|
|
||||||
named = append(named, fmt.Sprint(resource["id"]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, said := resource["owner"]; said || account == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, value := range mine {
|
|
||||||
if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") {
|
|
||||||
resource["owner"] = account
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(named)
|
|
||||||
return named
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module contributes to a seat it does not hold (novox/hq ADR 0212).
|
|
||||||
//
|
|
||||||
// ADR 0210 made a tool's configuration its seat holder's, and every other module's way in a
|
|
||||||
// contribution to the seat. The environment, the shell's slots and the power moments each became a
|
|
||||||
// field of their own; this is the general form, so a new seat that takes contributions is a row in
|
|
||||||
// the seat table rather than a change to the manifest: a contribution names a seat, a kind that
|
|
||||||
// seat receives, and text in the tool's own grammar, which the controller never reads.
|
|
||||||
|
|
||||||
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
|
|
||||||
const HotkeysSeat = "node-hotkeys"
|
|
||||||
|
|
||||||
// MessageBusSeat is the machine's D-Bus (novox/hq ADR 0215).
|
|
||||||
const MessageBusSeat = "node-message-bus"
|
|
||||||
|
|
||||||
// BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43).
|
|
||||||
const BackupSeat = "node-backup"
|
|
||||||
|
|
||||||
// SeatContribution is one piece of configuration a module gives a seat's holder to place.
|
|
||||||
type SeatContribution struct {
|
|
||||||
// Seat is the seat whose holder places it.
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
// Kind is which of the seat's receivable kinds it is.
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
// Content is the text, in the tool's own grammar. Never interpreted.
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
// Data is the piece in the seat's own shape, for a kind the seat receives as data (novox/hq ADR
|
|
||||||
// 0255): the holder renders it with its template, and Content is then empty.
|
|
||||||
Data map[string]any `json:"data,omitempty"`
|
|
||||||
// IfCapability names a capability the machine must report for this contribution to be placed
|
|
||||||
// there (novox/hq ADR 0255): a battery's block only where the node-engine found a battery. The
|
|
||||||
// composition decides, from the machine's facts, not the tool at run time.
|
|
||||||
IfCapability string `json:"if-capability,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ofContribution is where a holder places a kind: ${contribution:<seat>:<kind>}. Loose inside the
|
|
||||||
// braces, so a misspelt seat or kind is found and refused rather than written out as text.
|
|
||||||
var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`)
|
|
||||||
|
|
||||||
// receivable is what a seat receives of a kind, with the seat's canonical name; false when the seat
|
|
||||||
// is unknown or does not receive it.
|
|
||||||
func receivable(seat, kind string) (Seat, Receivable, bool) {
|
|
||||||
s, known := SeatNamed(seat)
|
|
||||||
if !known {
|
|
||||||
return Seat{}, Receivable{}, false
|
|
||||||
}
|
|
||||||
for _, r := range s.Receives {
|
|
||||||
if r.Kind == kind {
|
|
||||||
return s, r, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s, Receivable{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// kindsOf names a seat's receivable kinds for a refusal.
|
|
||||||
func kindsOf(s Seat) string {
|
|
||||||
if len(s.Receives) == 0 {
|
|
||||||
return "it receives no contributions"
|
|
||||||
}
|
|
||||||
var kinds []string
|
|
||||||
for _, r := range s.Receives {
|
|
||||||
kinds = append(kinds, r.Kind)
|
|
||||||
}
|
|
||||||
return "it receives " + strings.Join(kinds, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatContributionProblems is what is wrong with this module's contributions, from the manifest
|
|
||||||
// alone (novox/hq ADR 0212 §2).
|
|
||||||
func (m Manifest) seatContributionProblems() []string {
|
|
||||||
var problems []string
|
|
||||||
for i, c := range m.Contributions {
|
|
||||||
s, r, ok := receivable(c.Seat, c.Kind)
|
|
||||||
// A directory a contribution names must be one of this module's own, here rather than on the
|
|
||||||
// machine — where a `${dir:x}` nobody declared would reach the holder as the literal text.
|
|
||||||
if ok && r.Dirs {
|
|
||||||
declared := map[string]string{}
|
|
||||||
for _, res := range m.Resources {
|
|
||||||
if fmt.Sprint(res["type"]) == "directory" {
|
|
||||||
declared[fmt.Sprint(res["id"])] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := dirFill(c.Content, declared, m.Module); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d: %v", m.Module, i+1, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case s.Name == "":
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat))
|
|
||||||
case !ok:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)",
|
|
||||||
m.Module, i+1, s.Name, c.Kind, kindsOf(s)))
|
|
||||||
}
|
|
||||||
// Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a
|
|
||||||
// kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the
|
|
||||||
// capability would lose the piece without a word.
|
|
||||||
if c.IfCapability != "" {
|
|
||||||
switch {
|
|
||||||
case !capabilityName.MatchString(c.IfCapability):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+
|
|
||||||
"capability's name", m.Module, i+1, c.IfCapability))
|
|
||||||
case !oneOf(KnownCapabilities, c.IfCapability):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+
|
|
||||||
"reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(KnownCapabilities, ", ")))
|
|
||||||
case ok && !r.Offered:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+
|
|
||||||
"kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)",
|
|
||||||
m.Module, i+1, s.Name, c.Kind, c.IfCapability))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case ok && r.Shape != nil:
|
|
||||||
problems = append(problems, shapedProblems(m.Module, i, r, c)...)
|
|
||||||
problems = append(problems, ownStateProblems(m, i, r, c)...)
|
|
||||||
case len(c.Data) > 0:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+
|
|
||||||
"given as `content`", m.Module, i+1, s.Name, c.Kind))
|
|
||||||
case strings.TrimSpace(c.Content) == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a
|
|
||||||
// file's content, naming a seat and a kind it receives, and only by a module that claims that seat
|
|
||||||
// — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3).
|
|
||||||
func seatPlaceholderProblems(m Manifest, r map[string]any) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, field := range sortedKeys(r) {
|
|
||||||
v, ok := r[field].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found := ofContribution.FindAllStringSubmatch(v, -1)
|
|
||||||
if len(found) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if field != "content" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s in its %s; contributions are placed only in a file's content",
|
|
||||||
m.Module, r["id"], found[0][0], field))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, f := range found {
|
|
||||||
seat, rest, two := strings.Cut(f[1], ":")
|
|
||||||
// A kind received as data may be narrowed by its shape's placing fields (novox/hq ADR 0255).
|
|
||||||
kind, where, _ := strings.Cut(rest, ":")
|
|
||||||
s, rcv, ok := receivable(seat, kind)
|
|
||||||
if two && ok {
|
|
||||||
if _, err := placeholderWhere(rcv, where); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !two || !ok {
|
|
||||||
detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat)
|
|
||||||
if s.Name != "" {
|
|
||||||
detail = s.Name + ": " + kindsOf(s)
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s; a contribution is ${contribution:<seat>:<kind>} (%s)",
|
|
||||||
m.Module, r["id"], f[0], detail))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !m.ClaimsSeat(s.Name) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v names %s and %s does not claim %s; every module's contributions to a "+
|
|
||||||
"seat are placed by its holder alone (novox/hq ADR 0212)",
|
|
||||||
m.Module, r["id"], f[0], m.Module, s.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatContributions is every module's contribution of one kind to one seat (novox/hq ADR 0212 §3):
|
|
||||||
// in module order, each module's pieces in the order it declared them, each module's preceded by a
|
|
||||||
// comment line naming it in the tool's grammar, and empty when nothing is contributed. A kind that
|
|
||||||
// takes directories has each contributor's `${dir:<id>}` filled with where that module's directory
|
|
||||||
// is on this machine (novox/hq to-be 43).
|
|
||||||
//
|
|
||||||
// A directory placed in a person's home is named `${machine:account-home}/…` (novox/hq to-be 29); in a
|
|
||||||
// kind that takes directories that is filled from the machine's facts as well, so the holder reads a
|
|
||||||
// path, never a placeholder (novox/hq ADR 0233: an item in the operator's home is backed up too).
|
|
||||||
//
|
|
||||||
// A kind received as data is rendered through the holder's template instead, narrowed by where
|
|
||||||
// (novox/hq ADR 0255); and a contribution naming a capability this machine did not report is left
|
|
||||||
// out, of either form.
|
|
||||||
//
|
|
||||||
// What it could not render is answered as unplaced, each naming its module and why, and left out: one
|
|
||||||
// piece the holder's template does not know is that piece's fault, not the machine's.
|
|
||||||
func seatContributions(modules []Manifest, holder Manifest, placeholder string, with Rendering, facts map[string]string, caps map[string]bool) (string, []string, error) {
|
|
||||||
seat, rest, _ := strings.Cut(placeholder, ":")
|
|
||||||
kind, whereText, _ := strings.Cut(rest, ":")
|
|
||||||
s, r, ok := receivable(seat, kind)
|
|
||||||
if !ok {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
if r.Shape != nil {
|
|
||||||
where, err := placeholderWhere(r, whereText)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
return shapedContributions(modules, holder, facts["name"], facts, s, r, where, caps)
|
|
||||||
}
|
|
||||||
var failed error
|
|
||||||
var unplacedLines []string
|
|
||||||
var blocks []textBlock
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
var old strings.Builder
|
|
||||||
// A left-out module's lines are best effort (novox/hq ADR 0262): what cannot be placed is said,
|
|
||||||
// and the machine is declared without it. A placement setting that does not read is not
|
|
||||||
// replaced by the definition's own path, which may not be where the data is.
|
|
||||||
if m.bestEffort && r.Dirs {
|
|
||||||
if _, err := Places(m, with.Settings[m.Module]); err != nil {
|
|
||||||
unplacedLines = append(unplacedLines, fmt.Sprintf("%s's %s for %s, kept while it is left out, "+
|
|
||||||
"is not placed: its placement setting does not read (%v)", m.Module, kind, s.Name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range m.allContributions() {
|
|
||||||
if c.Kind != kind || !capable(c, caps) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content := c.Content
|
|
||||||
var lineFailed error
|
|
||||||
if r.Dirs {
|
|
||||||
filled, err := dirFill(content, dirsFor(m, with), m.Module)
|
|
||||||
if err != nil && lineFailed == nil {
|
|
||||||
lineFailed = err
|
|
||||||
}
|
|
||||||
// An operator's path the module was given, as an item of data on it (novox/hq ADR 0233).
|
|
||||||
if accessRef.MatchString(filled) {
|
|
||||||
_, byID, err := accessesFor(m, with.Settings[m.Module])
|
|
||||||
if err == nil {
|
|
||||||
filled, err = accessFill(filled, byID, m.Module)
|
|
||||||
}
|
|
||||||
if err != nil && lineFailed == nil {
|
|
||||||
lineFailed = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, key := range machineUsed(filled) {
|
|
||||||
value, has := facts[key]
|
|
||||||
if !has {
|
|
||||||
if lineFailed == nil {
|
|
||||||
lineFailed = fmt.Errorf("%s's %s for %s says ${machine:%s}, and this machine says %s",
|
|
||||||
m.Module, kind, s.Name, key, orNothing(namesOfFacts(facts)))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled = strings.ReplaceAll(filled, "${machine:"+key+"}", value)
|
|
||||||
}
|
|
||||||
content = filled
|
|
||||||
}
|
|
||||||
if lineFailed != nil {
|
|
||||||
if m.bestEffort {
|
|
||||||
unplacedLines = append(unplacedLines, fmt.Sprintf("%s's %s for %s, kept while it is left "+
|
|
||||||
"out, is not placed: %v", m.Module, kind, s.Name, lineFailed))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if failed == nil {
|
|
||||||
failed = lineFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if old.Len() == 0 {
|
|
||||||
fmt.Fprintf(&old, "%s %s\n", r.Comment, m.Module)
|
|
||||||
}
|
|
||||||
old.WriteString(content)
|
|
||||||
if !strings.HasSuffix(content, "\n") {
|
|
||||||
old.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var held []heldItem
|
|
||||||
if !r.Dirs {
|
|
||||||
held = heldItems(m, "contribution:"+s.Name+":"+kind, nil, facts, caps)
|
|
||||||
}
|
|
||||||
blocks = append(blocks, moduleBlocks(m, r.Comment, old.String(), held)...)
|
|
||||||
}
|
|
||||||
return joinBlocks(blocks), unplacedLines, failed
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"text/template"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A kind a seat receives as data, rendered by its holder (novox/hq ADR 0255).
|
|
||||||
//
|
|
||||||
// ADR 0212 made a contribution text in the tool's own grammar, which the controller places and never
|
|
||||||
// reads. That is right where the tool is the seat's for good — a hotkey daemon's trigger lines — and
|
|
||||||
// wrong where the seat's whole point is that its tool can be replaced: a module adding a battery to the
|
|
||||||
// bar would write i3status-rust's TOML, and a second bar would have to read it. So a seat may define a
|
|
||||||
// kind as data instead. The seat says the data's shape; a contributor gives data in that shape; the
|
|
||||||
// holder gives a template that renders one piece of it into its tool's grammar, as a module's facts
|
|
||||||
// template renders the roster (`facts`): the data is the mesh's, the format is the holder's.
|
|
||||||
//
|
|
||||||
// What the holder places is narrowed by the shape's placing fields — the bar's `bar` and `place` — in
|
|
||||||
// the placeholder, `${contribution:<seat>:<kind>:<field>=<value>,…}`, and every combination of them is
|
|
||||||
// placed by the holder exactly once, so nothing a module contributes is dropped by a holder that forgot
|
|
||||||
// a corner of the shape.
|
|
||||||
|
|
||||||
// Shape is what a kind received as data looks like.
|
|
||||||
type Shape struct {
|
|
||||||
// Places are the fields a holder narrows its placeholders by, each with every value it takes.
|
|
||||||
Places []PlaceField
|
|
||||||
// Order is the whole-number field that orders the pieces within one placeholder (lowest first,
|
|
||||||
// then module order); absent in a piece means OrderDefault.
|
|
||||||
Order string
|
|
||||||
// Check is what else is wrong with one piece's data, beyond its placing fields and its order.
|
|
||||||
Check func(data map[string]any) []string `json:"-"`
|
|
||||||
// Examples are pieces every holder's template must render, one for each variant the shape has,
|
|
||||||
// so a holder that cannot render one is refused at registration and not found on a machine.
|
|
||||||
Examples []map[string]any
|
|
||||||
// Reads is the state on the bus a piece shows, each a state and the one key of it, or nothing
|
|
||||||
// (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same
|
|
||||||
// machine is granted to read that key, so the holder's manifest names no contributor.
|
|
||||||
Reads func(data map[string]any) []StateRead `json:"-"`
|
|
||||||
// Text says every piece of this kind is, rendered, text that a collector may place as a collection of items
|
|
||||||
// (design 57 L3): a collector then names the kind as `${collected:contribution:<seat>:<kind>:<field>=<value>,…}`.
|
|
||||||
Text bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectableAsText is whether a collector may name a kind received as data as a collection.
|
|
||||||
func collectableAsText(r Receivable) bool { return r.Shape != nil && r.Shape.Text }
|
|
||||||
|
|
||||||
// PlaceField is one field a holder places by, and the values it takes.
|
|
||||||
type PlaceField struct {
|
|
||||||
Field string
|
|
||||||
Values []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderDefault is a piece's order when it gives none: the middle of 0–99.
|
|
||||||
const OrderDefault = 50
|
|
||||||
|
|
||||||
// capabilityName is the shape of a capability's name, as a node's profile reports it.
|
|
||||||
var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
// KnownCapabilities is every capability the node-engine detects (mesh-host internal/profile), the
|
|
||||||
// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its
|
|
||||||
// contribution out on every machine, silently, so it is refused. A detector added there is added here.
|
|
||||||
var KnownCapabilities = []string{
|
|
||||||
"battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
|
|
||||||
"power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager",
|
|
||||||
"uplink-systemd-networkd", "virtualisation",
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderFuncs are the functions a holder's template may call. `quote` writes a value as a JSON
|
|
||||||
// string with DEL escaped as well — JSON leaves it bare, and TOML refuses it — which makes it a valid
|
|
||||||
// basic string in TOML and in most tools' grammars, so a contributed command with quotes or control
|
|
||||||
// characters in it cannot break out of the holder's line.
|
|
||||||
var renderFuncs = template.FuncMap{"quote": quote}
|
|
||||||
|
|
||||||
func quote(v any) (string, error) {
|
|
||||||
b, err := json.Marshal(fmt.Sprint(v))
|
|
||||||
return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// shapedProblems is what is wrong with one contribution of a kind received as data.
|
|
||||||
func shapedProblems(module string, i int, r Receivable, c SeatContribution) []string {
|
|
||||||
var problems []string
|
|
||||||
at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind)
|
|
||||||
if strings.TrimSpace(c.Content) != "" {
|
|
||||||
problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)")
|
|
||||||
}
|
|
||||||
if len(c.Data) == 0 {
|
|
||||||
return append(problems, at+" has no data")
|
|
||||||
}
|
|
||||||
for _, p := range r.Shape.Places {
|
|
||||||
v, _ := c.Data[p.Field].(string)
|
|
||||||
if !oneOf(p.Values, v) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s",
|
|
||||||
at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.Shape.Order != "" {
|
|
||||||
if v, has := c.Data[r.Shape.Order]; has {
|
|
||||||
if _, ok := wholeIn(v, 0, 99); !ok {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99",
|
|
||||||
at, r.Shape.Order, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.Shape.Check != nil {
|
|
||||||
for _, p := range r.Shape.Check(c.Data) {
|
|
||||||
problems = append(problems, at+": "+p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownStateProblems is a data contribution showing state its module does not keep (novox/hq ADR 0255):
|
|
||||||
// a module offers the holder its own state and nobody else's, so what it publishes stays its own.
|
|
||||||
func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []string {
|
|
||||||
if r.Shape == nil || r.Shape.Reads == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
keeps := map[string]bool{}
|
|
||||||
for _, s := range m.State {
|
|
||||||
keeps[s.Name] = true
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, sr := range r.Shape.Reads(c.Data) {
|
|
||||||
module, local, err := ReadState(sr.Read)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err))
|
|
||||||
case module != m.Module || !keeps[local]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+
|
|
||||||
"module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module))
|
|
||||||
}
|
|
||||||
if sr.Key != "" && !stateKeyName.MatchString(sr.Key) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+
|
|
||||||
"digits and hyphens", m.Module, i+1, sr.Key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateRead is one key of another module's state that a holder reads: Read is `<module>.<name>`,
|
|
||||||
// and Key the one key, empty for the holder's own machine until ReadsGranted names it.
|
|
||||||
type StateRead struct {
|
|
||||||
Read, Key string
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateKeyName is a key a piece may name: a machine's name is one.
|
|
||||||
var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
// ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a
|
|
||||||
// kind its seat receives as data (novox/hq ADR 0260), each with the one key the piece shows — the
|
|
||||||
// machine's own when it names none — sorted and once each. The bus grants the holder these keys and
|
|
||||||
// no others: the holder names no contributor, and reads only what is offered to it where it runs.
|
|
||||||
func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead {
|
|
||||||
seen := map[StateRead]bool{}
|
|
||||||
for _, claim := range holder.Claims {
|
|
||||||
s, known := SeatNamed(claim.Name)
|
|
||||||
if !known {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, r := range s.Receives {
|
|
||||||
if r.Shape == nil || r.Shape.Reads == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, m := range onMachine {
|
|
||||||
for _, c := range m.allContributions() {
|
|
||||||
if cs, ok := SeatNamed(c.Seat); !ok || cs.Name != s.Name || c.Kind != r.Kind {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, sr := range r.Shape.Reads(c.Data) {
|
|
||||||
if sr.Key == "" {
|
|
||||||
sr.Key = machine
|
|
||||||
}
|
|
||||||
if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module &&
|
|
||||||
stateKeyName.MatchString(sr.Key) {
|
|
||||||
seen[sr] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]StateRead, 0, len(seen))
|
|
||||||
for sr := range seen {
|
|
||||||
out = append(out, sr)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Read != out[j].Read {
|
|
||||||
return out[i].Read < out[j].Read
|
|
||||||
}
|
|
||||||
return out[i].Key < out[j].Key
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float.
|
|
||||||
func wholeIn(v any, lo, hi int) (int, bool) {
|
|
||||||
f, ok := v.(float64)
|
|
||||||
if !ok {
|
|
||||||
if i, isInt := v.(int); isInt {
|
|
||||||
f, ok = float64(i), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !ok || f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return int(f), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// placeholderWhere parses the narrowing part of a placeholder, `bar=bottom,place=status`, against the
|
|
||||||
// kind's shape. Empty narrows nothing.
|
|
||||||
func placeholderWhere(r Receivable, where string) (map[string]string, error) {
|
|
||||||
out := map[string]string{}
|
|
||||||
if where == "" {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
if r.Shape == nil {
|
|
||||||
return nil, fmt.Errorf("%s is text, which a placeholder does not narrow", r.Kind)
|
|
||||||
}
|
|
||||||
for _, pair := range strings.Split(where, ",") {
|
|
||||||
field, value, ok := strings.Cut(pair, "=")
|
|
||||||
var p *PlaceField
|
|
||||||
for i := range r.Shape.Places {
|
|
||||||
if r.Shape.Places[i].Field == field {
|
|
||||||
p = &r.Shape.Places[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case !ok || p == nil:
|
|
||||||
return nil, fmt.Errorf("%q narrows by %s, and %s is placed by %s", where, field, r.Kind, placeFields(r))
|
|
||||||
case !oneOf(p.Values, value):
|
|
||||||
return nil, fmt.Errorf("%q says %s=%s; %s is one of %s", where, field, value, field, strings.Join(p.Values, ", "))
|
|
||||||
case out[field] != "":
|
|
||||||
return nil, fmt.Errorf("%q names %s twice", where, field)
|
|
||||||
}
|
|
||||||
out[field] = value
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func placeFields(r Receivable) string {
|
|
||||||
var names []string
|
|
||||||
for _, p := range r.Shape.Places {
|
|
||||||
names = append(names, p.Field)
|
|
||||||
}
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// holderTemplate is the holder's template for a kind, parsed.
|
|
||||||
func holderTemplate(holder Manifest, seat Seat, kind string) (*template.Template, error) {
|
|
||||||
for _, c := range holder.Claims {
|
|
||||||
if s, known := SeatNamed(c.Name); !known || s.Name != seat.Name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text, has := c.Renders[kind]
|
|
||||||
if !has || strings.TrimSpace(text) == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
t, err := template.New(seat.Name + ":" + kind).Funcs(renderFuncs).Parse(text)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s's template for %s:%s does not parse: %v", holder.Module, seat.Name, kind, err)
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%s places %s:%s, which is data, and its claim gives no template for it in `renders` "+
|
|
||||||
"(novox/hq ADR 0255)", holder.Module, seat.Name, kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPiece is one piece of data in the holder's grammar. The template sees the piece's fields,
|
|
||||||
// `module`, the contributor, and `machine`, the node it is composed for. A value the piece does not have reads as nothing in a `with` or an `if`;
|
|
||||||
// printed bare it would write `<no value>` into the tool's file, which is refused here instead.
|
|
||||||
func renderPiece(t *template.Template, module, machine string, data map[string]any) (string, error) {
|
|
||||||
view := map[string]any{"module": module, "machine": machine}
|
|
||||||
for k, v := range data {
|
|
||||||
view[k] = v
|
|
||||||
}
|
|
||||||
var b bytes.Buffer
|
|
||||||
if err := t.Execute(&b, view); err != nil {
|
|
||||||
return "", fmt.Errorf("rendering %s's piece: %v", module, err)
|
|
||||||
}
|
|
||||||
out := b.String()
|
|
||||||
if strings.Contains(out, "<no value>") {
|
|
||||||
return "", fmt.Errorf("rendering %s's piece printed a value it does not have: %q", module, out)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// placedPiece is one contributed piece on its way into a holder's file.
|
|
||||||
type placedPiece struct {
|
|
||||||
module string
|
|
||||||
data map[string]any
|
|
||||||
order int
|
|
||||||
// text is a moved contributor's item, rendered (collections.go heldItems): placed as it is, not through the
|
|
||||||
// holder's template.
|
|
||||||
text string
|
|
||||||
item bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// shapedContributions is every module's data of one kind to one seat, narrowed by where and by the
|
|
||||||
// machine's capabilities, ordered, and rendered through the holder's template — each piece under a
|
|
||||||
// comment line naming its module.
|
|
||||||
//
|
|
||||||
// A piece the template renders to nothing — a `shows` the holder does not know yet — or fails on is
|
|
||||||
// left out and answered as unplaced, naming its module: the other pieces and the rest of the machine's
|
|
||||||
// declaration go on (novox/hq ADR 0255).
|
|
||||||
func shapedContributions(modules []Manifest, holder Manifest, machine string, facts map[string]string, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) {
|
|
||||||
var pieces []placedPiece
|
|
||||||
for _, m := range inModuleOrder(modules) {
|
|
||||||
for _, c := range m.allContributions() {
|
|
||||||
if c.Kind != r.Kind || !capable(c, caps) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cs, known := SeatNamed(c.Seat); !known || cs.Name != s.Name {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
match := true
|
|
||||||
for field, value := range where {
|
|
||||||
if v, _ := c.Data[field].(string); v != value {
|
|
||||||
match = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !match {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
order := OrderDefault
|
|
||||||
if v, ok := wholeIn(c.Data[r.Shape.Order], 0, 99); ok {
|
|
||||||
order = v
|
|
||||||
}
|
|
||||||
pieces = append(pieces, placedPiece{module: m.Module, data: c.Data, order: order})
|
|
||||||
}
|
|
||||||
// A moved contributor's items of the kind, by their attributes (design 57 L3, issue 777): each in its
|
|
||||||
// module's place, by its order, then its name.
|
|
||||||
if !r.Dirs {
|
|
||||||
for _, h := range heldItems(m, "contribution:"+s.Name+":"+r.Kind, where, facts, caps) {
|
|
||||||
pieces = append(pieces, placedPiece{module: m.Module, order: h.item.order(), text: h.text, item: true})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(pieces) == 0 {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
var t *template.Template
|
|
||||||
for _, p := range pieces {
|
|
||||||
if p.item {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if t, err = holderTemplate(holder, s, r.Kind); err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order })
|
|
||||||
var b strings.Builder
|
|
||||||
var unplaced []string
|
|
||||||
for _, p := range pieces {
|
|
||||||
if p.item {
|
|
||||||
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
|
|
||||||
b.WriteString(p.text)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out, err := renderPiece(t, p.module, machine, p.data)
|
|
||||||
if err == nil && strings.TrimSpace(out) == "" {
|
|
||||||
err = fmt.Errorf("%s's template renders nothing for it", holder.Module)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
unplaced = append(unplaced, fmt.Sprintf("%s's %s:%s %s is not placed by %s: %v",
|
|
||||||
p.module, s.Name, r.Kind, describePiece(p.data), holder.Module, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
|
|
||||||
b.WriteString(out)
|
|
||||||
if !strings.HasSuffix(out, "\n") {
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String(), unplaced, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// describePiece is a piece in one line, its fields in order, for a person reading why it was left out.
|
|
||||||
func describePiece(data map[string]any) string {
|
|
||||||
var parts []string
|
|
||||||
for _, k := range sortedKeys(data) {
|
|
||||||
if _, nested := data[k].(map[string]any); nested {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts = append(parts, fmt.Sprintf("%s=%v", k, data[k]))
|
|
||||||
}
|
|
||||||
return "(" + strings.Join(parts, " ") + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
// capable is whether a contribution applies on a machine with these capabilities: always, unless it
|
|
||||||
// names one with `if-capability` the machine did not report (novox/hq ADR 0255). A machine whose
|
|
||||||
// capabilities are not known has none, so a conditional piece is left out rather than guessed in.
|
|
||||||
func capable(c SeatContribution, caps map[string]bool) bool {
|
|
||||||
return c.IfCapability == "" || caps[c.IfCapability]
|
|
||||||
}
|
|
||||||
|
|
||||||
// placementProblems is what is wrong with how a holder places the kinds its seats receive as data:
|
|
||||||
// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing
|
|
||||||
// fields placed exactly once across its files — or something contributed would be dropped, or written
|
|
||||||
// twice (novox/hq ADR 0255).
|
|
||||||
func (m Manifest) placementProblems() []string {
|
|
||||||
var problems []string
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
s, known := SeatNamed(c.Name)
|
|
||||||
if !known {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, r := range s.Receives {
|
|
||||||
// A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it
|
|
||||||
// would refuse every bar registered before the kind existed, and the controller that
|
|
||||||
// defines the kind ships first. Its contributions wait, unplaced, until it places them.
|
|
||||||
if r.Shape == nil || !placesKind(m, s, r.Kind) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t, err := holderTemplate(m, s, r.Kind)
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ex := range r.Shape.Examples {
|
|
||||||
if out, err := renderPiece(t, "example", "machine", ex); err != nil || strings.TrimSpace(out) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v",
|
|
||||||
m.Module, s.Name, r.Kind, ex, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placed := map[string]int{}
|
|
||||||
for _, res := range m.Resources {
|
|
||||||
content, _ := res["content"].(string)
|
|
||||||
for _, f := range ofContribution.FindAllStringSubmatch(content, -1) {
|
|
||||||
seat, rest, _ := strings.Cut(f[1], ":")
|
|
||||||
kind, whereText, _ := strings.Cut(rest, ":")
|
|
||||||
if cs, ok := SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
where, err := placeholderWhere(r, whereText)
|
|
||||||
if err != nil {
|
|
||||||
continue // said by seatPlaceholderProblems
|
|
||||||
}
|
|
||||||
for _, combo := range combinations(r.Shape.Places) {
|
|
||||||
if covers(where, combo) {
|
|
||||||
placed[comboKey(combo)]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, combo := range combinations(r.Shape.Places) {
|
|
||||||
switch n := placed[comboKey(combo)]; {
|
|
||||||
case n == 0:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+
|
|
||||||
"placed once, or a contribution to it is lost (novox/hq ADR 0255)",
|
|
||||||
m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r)))
|
|
||||||
case n > 1:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+
|
|
||||||
"is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// placesKind is whether any of a holder's files names the kind's placeholder.
|
|
||||||
func placesKind(m Manifest, s Seat, kind string) bool {
|
|
||||||
for _, res := range m.Resources {
|
|
||||||
content, _ := res["content"].(string)
|
|
||||||
for _, f := range ofContribution.FindAllStringSubmatch(content, -1) {
|
|
||||||
seat, rest, _ := strings.Cut(f[1], ":")
|
|
||||||
k, _, _ := strings.Cut(rest, ":")
|
|
||||||
if cs, ok := SeatNamed(seat); ok && cs.Name == s.Name && k == kind {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// combinations is every assignment of a value to each placing field.
|
|
||||||
func combinations(places []PlaceField) []map[string]string {
|
|
||||||
out := []map[string]string{{}}
|
|
||||||
for _, p := range places {
|
|
||||||
var next []map[string]string
|
|
||||||
for _, partial := range out {
|
|
||||||
for _, v := range p.Values {
|
|
||||||
c := map[string]string{p.Field: v}
|
|
||||||
for k, w := range partial {
|
|
||||||
c[k] = w
|
|
||||||
}
|
|
||||||
next = append(next, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = next
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func covers(where, combo map[string]string) bool {
|
|
||||||
for field, value := range where {
|
|
||||||
if combo[field] != value {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func comboKey(combo map[string]string) string {
|
|
||||||
var parts []string
|
|
||||||
for _, k := range sortedKeys(combo) {
|
|
||||||
parts = append(parts, k+"="+combo[k])
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ",")
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module depends on the node seats that apply its resources (novox/hq ADR 0207), on the
|
|
||||||
// seats it contributes to (novox/hq ADR 0210), and on the seats a seat it holds needs beside it
|
|
||||||
// (novox/hq ADR 0220). The third source once served only compiled seats and had one user,
|
|
||||||
// node-resolver-config needing node-uplink, which went with that seat when the resolver file became
|
|
||||||
// the uplink's own (novox/hq ADR 0223) — and now serves module-declared seats instead: mail-notifier
|
|
||||||
// needing node-notifier, and the like (novox/hq ADR 0378 §8).
|
|
||||||
//
|
|
||||||
// Some of what a module declares is applied through software on the machine that is itself a
|
|
||||||
// module: a service through the service manager, a package through the package manager, a container
|
|
||||||
// through the container runtime. A *capability* only says that software is installed; it does not
|
|
||||||
// say that a module of the mesh holds the role and answers for it. So the dependency is derived from
|
|
||||||
// the resources — never stated in a manifest, because a module that adds a service and forgets a
|
|
||||||
// field would pass — and is met when some module assigned to the same node holds the seat.
|
|
||||||
|
|
||||||
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
|
||||||
// the seed and the messages all turn on these strings.
|
|
||||||
const (
|
|
||||||
ServiceManagerSeat = "node-service-manager"
|
|
||||||
PackageManagerSeat = "node-package-manager"
|
|
||||||
ContainerRuntimeSeat = "node-container-runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
|
||||||
// names them and no more. A process is supervised by the host itself, a file, a directory, an
|
|
||||||
// archive, a user or an action is the host's own act, and a module's other kinds reach the machine
|
|
||||||
// without a role in between — adding one here is a decision, not a refinement.
|
|
||||||
var appliedThrough = map[string]string{
|
|
||||||
"service": ServiceManagerSeat,
|
|
||||||
"package": PackageManagerSeat,
|
|
||||||
"container": ContainerRuntimeSeat,
|
|
||||||
}
|
|
||||||
|
|
||||||
// enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at
|
|
||||||
// composition is *reported* — in the resolution, in `status`, once in the log — and the node still
|
|
||||||
// resolves; on, it is refused like any unresolved requirement. Off until `status` reports none,
|
|
||||||
// which is when the three holders are assigned to every node: switching it before then would stop
|
|
||||||
// every machine lacking one from being sent anything at all.
|
|
||||||
//
|
|
||||||
// Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd,
|
|
||||||
// pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42).
|
|
||||||
//
|
|
||||||
// A variable rather than a constant only so a test can hold both behaviours; nothing else sets it.
|
|
||||||
var enforceSeatDependencies = true
|
|
||||||
|
|
||||||
// EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other
|
|
||||||
// packages that hold the behaviour from before the switch; nothing else calls it.
|
|
||||||
func EnforcingSeatDependencies(on bool) (restore func()) {
|
|
||||||
was := enforceSeatDependencies
|
|
||||||
enforceSeatDependencies = on
|
|
||||||
return func() { enforceSeatDependencies = was }
|
|
||||||
}
|
|
||||||
|
|
||||||
// foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5):
|
|
||||||
// the host and the private network. Registered as modules so they can be assigned, but what they
|
|
||||||
// declare is the installation's, not a module's, so it is never judged. The third piece, the
|
|
||||||
// bootstrap container runtime, is not a module at all: its package and service are in the genesis
|
|
||||||
// bundle the host applies itself, and never pass through a resolution here.
|
|
||||||
//
|
|
||||||
// The private network's module is overlay.Name, written out because the overlay package composes
|
|
||||||
// on top of this one; its resources are computed, which exempts it by the rule below as well.
|
|
||||||
var foundationModules = map[string]bool{
|
|
||||||
"mesh-host": true,
|
|
||||||
"mesh-wireguard": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isFoundation is whether a module's declarations are the foundation's rather than its own. A
|
|
||||||
// module whose resources are computed is the mesh's by construction — the private network's peer
|
|
||||||
// list and its tools are the controller's, written per node — so it counts whatever its name.
|
|
||||||
func isFoundation(m Manifest) bool {
|
|
||||||
return foundationModules[m.Module] || m.Computed != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// DependsOn is every seat a module needs held on its node: derived from the resource types it
|
|
||||||
// declares itself (novox/hq ADR 0207 §2), from what it contributes to (ADR 0210), and from what a
|
|
||||||
// node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that
|
|
||||||
// seat (novox/hq ADR 0220, ADR 0378 §8). Sorted.
|
|
||||||
//
|
|
||||||
// **The module's own `resources` only**, for the first source. What the controller composes around
|
|
||||||
// a module — its filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put
|
|
||||||
// there because the module is assigned, and depending on it would make the module answer for the
|
|
||||||
// mesh's choices.
|
|
||||||
func DependsOn(catalogue map[string]Manifest, m Manifest) []string {
|
|
||||||
if isFoundation(m) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied {
|
|
||||||
seen[seat] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, seat := range contributedTo(m) {
|
|
||||||
seen[seat] = true
|
|
||||||
}
|
|
||||||
for _, seat := range neededBesideClaims(catalogue, m) {
|
|
||||||
seen[seat] = true
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(seen))
|
|
||||||
for s := range seen {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is
|
|
||||||
// configuration only the seat's holder applies, so it is a dependency on that seat exactly as a
|
|
||||||
// resource is on the seat that applies it. The environment goes to node-environment's holder
|
|
||||||
// (ADR 0203); shell code to the holder that places it for its target — the login shell's for a
|
|
||||||
// shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4).
|
|
||||||
func contributedTo(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) {
|
|
||||||
out = append(out, EnvironmentSeat)
|
|
||||||
}
|
|
||||||
for _, c := range m.Shell {
|
|
||||||
out = append(out, placerOf(c.For))
|
|
||||||
}
|
|
||||||
// Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the
|
|
||||||
// mesh does not define is refused at registration and depends on nothing here.
|
|
||||||
// A kind the seat only offers to place makes no dependency (novox/hq ADR 0255).
|
|
||||||
for _, c := range m.Contributions {
|
|
||||||
if s, r, ok := receivable(c.Seat, c.Kind); ok && r.Offered {
|
|
||||||
continue
|
|
||||||
} else if s.Name != "" {
|
|
||||||
out = append(out, s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And the backup holder for what is irreplaceable (novox/hq ADR 0233): it backs it up or watches the
|
|
||||||
// array it is on. What is valuable is backed up where a holder is — the standard plan — and makes
|
|
||||||
// no machine refuse it for want of one.
|
|
||||||
if m.NeedsBackupHolder() {
|
|
||||||
out = append(out, BackupSeat)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// neededBesideClaims is every seat a node seat the module claims needs held on the same node
|
|
||||||
// (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest
|
|
||||||
// in the catalogue declares that seat, never written again in the claimant's own manifest. Derived
|
|
||||||
// from the claim, as a resource's seat is derived from its type, so a module that claims the seat
|
|
||||||
// cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8).
|
|
||||||
func neededBesideClaims(catalogue map[string]Manifest, m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
for _, seat := range nodeSeatsClaimed(m) {
|
|
||||||
for _, candidate := range catalogue {
|
|
||||||
for _, s := range candidate.DefinesSeats {
|
|
||||||
if s.Name == seat {
|
|
||||||
out = append(out, s.Needs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimsSeat is whether a module claims a node seat, by its current name or one it used to have
|
|
||||||
// (ADR 0122), so a rename leaves the dependency met.
|
|
||||||
func claimsSeat(m Manifest, seat string) bool {
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if c.At() != ScopeNode {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := c.Name
|
|
||||||
if s, known := SeatNamed(name); known {
|
|
||||||
name = s.Name
|
|
||||||
}
|
|
||||||
if name == seat {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// PossibleHolders is every module in the catalogue that claims a seat at node scope — what a
|
|
||||||
// refusal names as the remedy.
|
|
||||||
func PossibleHolders(catalogue map[string]Manifest, seat string) []string {
|
|
||||||
var out []string
|
|
||||||
for name, m := range catalogue {
|
|
||||||
if claimsSeat(m, seat) {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unheld is one dependency of one module on a node that nothing on that node holds.
|
|
||||||
type Unheld struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
// Holders are the modules in the catalogue that could hold the seat: assigning one meets it.
|
|
||||||
Holders []string `json:"holders"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// String is the line a refusal and a report both say, so the two never drift.
|
|
||||||
func (u Unheld) String() string {
|
|
||||||
remedy := "and no module in the catalogue claims it yet"
|
|
||||||
if len(u.Holders) > 0 {
|
|
||||||
remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ")
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s",
|
|
||||||
u.Module, u.Node, u.Seat, u.Node, remedy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnheldDependencies is every dependency of the modules in `judged` that the node's whole set
|
|
||||||
// leaves unmet (novox/hq ADR 0207 §3).
|
|
||||||
//
|
|
||||||
// **Judged over the whole set, never one module at a time.** The holders depend on each other:
|
|
||||||
// the service manager's own package needs the package manager, and the package manager's timer
|
|
||||||
// needs the service manager. Asked one by one, neither could ever be first; asked of the set, the
|
|
||||||
// two assigned together meet each other. A module holding a seat it depends on meets its own
|
|
||||||
// dependency. `judged` nil judges every module of the set.
|
|
||||||
func UnheldDependencies(catalogue map[string]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld {
|
|
||||||
held := map[string]bool{}
|
|
||||||
for _, m := range set {
|
|
||||||
for _, seat := range nodeSeatsClaimed(m) {
|
|
||||||
held[seat] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var out []Unheld
|
|
||||||
for _, m := range set {
|
|
||||||
if judged != nil && !judged[m.Module] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, seat := range DependsOn(catalogue, m) {
|
|
||||||
if held[seat] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, Unheld{Node: node, Module: m.Module, Seat: seat,
|
|
||||||
Holders: PossibleHolders(catalogue, seat)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Module != out[j].Module {
|
|
||||||
return out[i].Module < out[j].Module
|
|
||||||
}
|
|
||||||
return out[i].Seat < out[j].Seat
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so
|
|
||||||
// a dependency on any of them — a resource's or a contribution's — is met by the claim.
|
|
||||||
func nodeSeatsClaimed(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if c.At() != ScopeNode {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := c.Name
|
|
||||||
if s, known := SeatNamed(name); known {
|
|
||||||
name = s.Name
|
|
||||||
}
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not
|
|
||||||
// know contributes nothing, as it does to a resolution.
|
|
||||||
func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest {
|
|
||||||
var out []Manifest
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, n := range names {
|
|
||||||
if m, known := catalogue[n]; known && !seen[n] {
|
|
||||||
seen[n] = true
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or
|
|
||||||
// nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones
|
|
||||||
// included, leave unmet.
|
|
||||||
//
|
|
||||||
// **Only the new modules are judged.** A node already short of a holder is reported by `status`;
|
|
||||||
// refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too.
|
|
||||||
//
|
|
||||||
// **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the
|
|
||||||
// module that would meet it; with none registered there is no remedy to name, and refusing would
|
|
||||||
// stop every assignment of that kind until a module that does not exist yet is written. The answer
|
|
||||||
// still says it, and `status` reports it — before the switch and after it alike: there is never a
|
|
||||||
// remedy to name for it. The first return is those lines.
|
|
||||||
func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) {
|
|
||||||
set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))
|
|
||||||
judged := map[string]bool{}
|
|
||||||
for _, a := range adding {
|
|
||||||
judged[a] = true
|
|
||||||
}
|
|
||||||
var refused, said []string
|
|
||||||
for _, u := range UnheldDependencies(catalogue, node, set, judged) {
|
|
||||||
if len(u.Holders) == 0 {
|
|
||||||
said = append(said, u.String())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
refused = append(refused, u.String())
|
|
||||||
}
|
|
||||||
if len(refused) > 0 {
|
|
||||||
return said, &Refusal{Problems: append(refused,
|
|
||||||
fmt.Sprintf("holders that depend on each other are assigned together: `assign %s <module> <module>…`", node))}
|
|
||||||
}
|
|
||||||
return said, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing
|
|
||||||
// (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while
|
|
||||||
// a module left there depends on it. Names the dependents, because they are what must go first —
|
|
||||||
// or the holder's replacement come.
|
|
||||||
func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error {
|
|
||||||
gone := map[string]bool{}
|
|
||||||
for _, r := range removing {
|
|
||||||
gone[r] = true
|
|
||||||
}
|
|
||||||
var left []string
|
|
||||||
for _, a := range assigned {
|
|
||||||
if !gone[a] {
|
|
||||||
left = append(left, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
before := map[string]bool{}
|
|
||||||
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) {
|
|
||||||
before[u.Module+"\x00"+u.Seat] = true
|
|
||||||
}
|
|
||||||
dependents := map[string][]string{}
|
|
||||||
for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) {
|
|
||||||
if before[u.Module+"\x00"+u.Seat] {
|
|
||||||
continue // unmet already; not this removal's doing
|
|
||||||
}
|
|
||||||
dependents[u.Seat] = append(dependents[u.Seat], u.Module)
|
|
||||||
}
|
|
||||||
if len(dependents) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seats := make([]string, 0, len(dependents))
|
|
||||||
for s := range dependents {
|
|
||||||
seats = append(seats, s)
|
|
||||||
}
|
|
||||||
sort.Strings(seats)
|
|
||||||
var problems []string
|
|
||||||
for _, s := range seats {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+
|
|
||||||
"or assign another holder first", strings.Join(removing, ", "), s, node,
|
|
||||||
strings.Join(dependents[s], ", ")))
|
|
||||||
}
|
|
||||||
return &Refusal{Problems: problems}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
|
|
||||||
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235): one path, unit or name, or a package one
|
|
||||||
// declares present and another absent. A package several modules declare present is no collision (ADR 0303).
|
|
||||||
//
|
|
||||||
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
|
|
||||||
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
|
|
||||||
// A collision is not an order anything can complete — no further assignment makes two owners of one
|
|
||||||
// file one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
|
|
||||||
// it from the mesh. Only collisions involving a module being added are refused; one already on the
|
|
||||||
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
|
|
||||||
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
|
|
||||||
before := map[string]bool{}
|
|
||||||
for _, p := range checkResources(manifestsOf(catalogue, assigned)) {
|
|
||||||
before[p] = true
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, p := range checkResources(manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...))) {
|
|
||||||
if before[p] {
|
|
||||||
continue // on the node already; not this assignment's doing
|
|
||||||
}
|
|
||||||
if strings.Contains(p, "ADR 0303") {
|
|
||||||
problems = append(problems, p) // a package declared present and absent says its own rule
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
|
|
||||||
"depends on the owner's seat instead)")
|
|
||||||
}
|
|
||||||
if len(problems) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sort.Strings(problems)
|
|
||||||
return &Refusal{Problems: append(problems, fmt.Sprintf("nothing was assigned to %s", node))}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Telling a module where this machine put the holder of a seat.
|
|
||||||
//
|
|
||||||
// **The foundation's ports are the node's** (novox/hq ADR 0100): the port a foundation server was
|
|
||||||
// given at genesis becomes that node's setting for the module that serves it, and every reader
|
|
||||||
// follows the setting. Every consumer's binding did. The control plane's own connections did not
|
|
||||||
// (04-ISSUES/102): they are written at genesis, before any module exists to bind to — full
|
|
||||||
// connection strings, sealed, with the port inside — so when the node moved the store, the
|
|
||||||
// control plane went on dialling where genesis had written and the mesh was headless.
|
|
||||||
//
|
|
||||||
// The control plane cannot open its own sealed connection to move the port, and it cannot bind
|
|
||||||
// the store as a consumer would: a binding mints a credential, and what the control plane holds
|
|
||||||
// is the foundation's superuser, made before the mesh. What it can do is read the node's settings
|
|
||||||
// when it composes its own declaration — it is the thing that composes every other module's — and
|
|
||||||
// say in its own environment which port this machine put the store at.
|
|
||||||
//
|
|
||||||
// So a module may ask about a **seat** (ADR 0079: a foundation seat is named after the server it
|
|
||||||
// guards — `mesh-store`, `mesh-broker`). `${seat:mesh-store:5432}` is "the port this machine put
|
|
||||||
// the holder of the mesh-store seat's 5432 at". Not a provision: nothing is required, nothing is
|
|
||||||
// granted, no credential is minted. A seat is the mesh's own vocabulary for the store and the
|
|
||||||
// broker, which is what makes this the control plane's way of naming them and not a way for a
|
|
||||||
// module to reach a server it was not granted — the answer is a port number the mesh holds in the
|
|
||||||
// clear, and the credential to use it is still the module's own to have.
|
|
||||||
//
|
|
||||||
// **The answer may be empty, and that is the one place a placeholder answers with nothing.** The
|
|
||||||
// store and the broker are raised at genesis, before the mesh knows them as modules; a mesh raised
|
|
||||||
// on the catalogue's own ports never gives them a setting at all. In both, the port genesis wrote
|
|
||||||
// into the connection string is the right one, and the mesh has nothing to add. An empty answer
|
|
||||||
// says exactly that, and what reads it — the control plane's `_PORT` twin — treats an empty value
|
|
||||||
// as no value. Answering with the software's own port instead would override what genesis wrote
|
|
||||||
// with a number the mesh never checked, on the one machine where that is a headless mesh.
|
|
||||||
|
|
||||||
// ofSeat is where a module asks about a seat: ${seat:<seat>:<the port its holder's software uses>}.
|
|
||||||
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
|
||||||
|
|
||||||
// **Where this machine reaches a mesh seat's holder** (novox/hq ADR 0222, issue 190).
|
|
||||||
//
|
|
||||||
// `${seat:<mesh-seat>:reach}` is host:port — the address this machine dials to reach whatever holds a
|
|
||||||
// seat the mesh holds once. The same reasoning as the port above, one step further: nothing is
|
|
||||||
// required, nothing is granted, no credential is minted, and the answer is an address the mesh
|
|
||||||
// already holds in the clear and composes into every reference it built. What it is for is a module
|
|
||||||
// that must *state* where a mesh service is to software it owns — the container runtime trusting
|
|
||||||
// the mesh's registry is the case — without becoming that service's consumer.
|
|
||||||
//
|
|
||||||
// Answered for the seats in reachedSeats only. Another seat is refused by name rather than answered
|
|
||||||
// with nothing: a module asking where something is that the mesh does not say would otherwise be
|
|
||||||
// given an empty value and never know the question was not understood.
|
|
||||||
//
|
|
||||||
// The answer may be empty: no machine on the private network holds the seat yet (genesis raises
|
|
||||||
// the store before the network). In a file written into as JSON, an empty member is dropped from
|
|
||||||
// its list, and a list left with none is dropped, so the runtime is never told to trust "".
|
|
||||||
var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`)
|
|
||||||
|
|
||||||
// reachedSeats is every seat ${seat:…:reach} answers for.
|
|
||||||
var reachedSeats = map[string]bool{"mesh-artifact-store": true}
|
|
||||||
|
|
||||||
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
|
|
||||||
// holder — in a file's content, and in a value of a container's or a process's environment. The
|
|
||||||
// same places portInto fills, for the same reason: they are where a program reads a number from.
|
|
||||||
func seatInto(resource map[string]any, module string, with Rendering) error {
|
|
||||||
switch fmt.Sprint(resource["type"]) {
|
|
||||||
case "file":
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok || (!ofSeat.MatchString(content) && !ofSeatReach.MatchString(content)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
where := fmt.Sprintf("%s has a file that", module)
|
|
||||||
filled, err := seatsFilledInto(content, where, with)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if ofSeatReach.MatchString(filled) {
|
|
||||||
if filled, err = reachFilledInto(filled, where, with); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if fmt.Sprint(resource["into"]) == "json" {
|
|
||||||
if filled, err = withoutEmptyMembers(filled, where); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["content"] = filled
|
|
||||||
|
|
||||||
case "container", "process":
|
|
||||||
env, ok := resource["env"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
named := make([]string, 0, len(env))
|
|
||||||
for key := range env {
|
|
||||||
named = append(named, key)
|
|
||||||
}
|
|
||||||
sort.Strings(named)
|
|
||||||
|
|
||||||
// A fresh map, and only when something changes — this map is the catalogue's, shared by
|
|
||||||
// every node running the module (see portInto).
|
|
||||||
var filled map[string]any
|
|
||||||
for _, key := range named {
|
|
||||||
written, ok := env[key].(string)
|
|
||||||
if !ok || (!ofSeat.MatchString(written) && !ofSeatReach.MatchString(written)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
where := fmt.Sprintf("%s's %s %s sets %s to something that",
|
|
||||||
module, resource["type"], resource["name"], key)
|
|
||||||
value, err := seatsFilledInto(written, where, with)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if value, err = reachFilledInto(value, where, with); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if filled == nil {
|
|
||||||
filled = map[string]any{}
|
|
||||||
for k, v := range env {
|
|
||||||
filled[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filled[key] = value
|
|
||||||
}
|
|
||||||
if filled != nil {
|
|
||||||
resource["env"] = filled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatsFilledInto answers every ${seat:…} in one written value.
|
|
||||||
//
|
|
||||||
// A port the seat's holder does not publish on this machine — or a seat nothing on it holds —
|
|
||||||
// answers with nothing, for the reason the package comment gives. A port that is not one is
|
|
||||||
// refused: it was written by a person and it is wrong.
|
|
||||||
func seatsFilledInto(written, where string, with Rendering) (string, error) {
|
|
||||||
for _, m := range ofSeat.FindAllStringSubmatch(written, -1) {
|
|
||||||
seat, port := m[1], m[2]
|
|
||||||
wanted, err := strconv.Atoi(port)
|
|
||||||
if err != nil || wanted < 1 || wanted > 65535 {
|
|
||||||
return "", fmt.Errorf("%s says ${seat:%s:%s}, and %s is not a port", where, seat, port, port)
|
|
||||||
}
|
|
||||||
answer := ""
|
|
||||||
if at, known := with.Seats[seat][wanted]; known {
|
|
||||||
answer = strconv.Itoa(at)
|
|
||||||
}
|
|
||||||
written = strings.ReplaceAll(written, m[0], answer)
|
|
||||||
}
|
|
||||||
return written, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// reachFilledInto answers every ${seat:…:reach} in one written value with where this machine
|
|
||||||
// reaches the seat's holder, or with nothing when no holder is reachable yet. A seat the mesh does
|
|
||||||
// not answer this for is refused by name.
|
|
||||||
func reachFilledInto(written, where string, with Rendering) (string, error) {
|
|
||||||
for _, m := range ofSeatReach.FindAllStringSubmatch(written, -1) {
|
|
||||||
seat := m[1]
|
|
||||||
if !reachedSeats[seat] {
|
|
||||||
known := make([]string, 0, len(reachedSeats))
|
|
||||||
for s := range reachedSeats {
|
|
||||||
known = append(known, s)
|
|
||||||
}
|
|
||||||
sort.Strings(known)
|
|
||||||
return "", fmt.Errorf("%s says ${seat:%s:reach}, and the mesh says where a seat's holder is "+
|
|
||||||
"reached only for %s (novox/hq ADR 0222)", where, seat, strings.Join(known, ", "))
|
|
||||||
}
|
|
||||||
written = strings.ReplaceAll(written, m[0], with.SeatReach[seat])
|
|
||||||
}
|
|
||||||
return written, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withoutEmptyMembers drops every empty string from the lists at the top of a JSON object written
|
|
||||||
// into a machine's file, and a list left with no members, so an unanswered ${seat:…:reach} adds
|
|
||||||
// nothing to the machine's list rather than adding "".
|
|
||||||
func withoutEmptyMembers(content, where string) (string, error) {
|
|
||||||
var object map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal([]byte(content), &object); err != nil {
|
|
||||||
return "", fmt.Errorf("%s is written into as JSON and is not a JSON object: %w", where, err)
|
|
||||||
}
|
|
||||||
changed := false
|
|
||||||
for key, raw := range object {
|
|
||||||
var members []json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &members); err != nil {
|
|
||||||
continue // not a list
|
|
||||||
}
|
|
||||||
kept := make([]json.RawMessage, 0, len(members))
|
|
||||||
for _, member := range members {
|
|
||||||
var s string
|
|
||||||
if json.Unmarshal(member, &s) == nil && s == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, member)
|
|
||||||
}
|
|
||||||
if len(kept) == len(members) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
if len(kept) == 0 {
|
|
||||||
delete(object, key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
list, err := json.Marshal(kept)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
object[key] = list
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
out, err := json.Marshal(object)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(out) + "\n", nil
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An assignment as the passing of a seat reads it: a module on a machine, with its manifest.
|
|
||||||
type Assignment struct {
|
|
||||||
Node string
|
|
||||||
Manifest Manifest
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatPass is one seat whose holder on record is assigned nowhere, and the only assignment that can take it.
|
|
||||||
type SeatPass struct {
|
|
||||||
Seat Seat
|
|
||||||
From Held
|
|
||||||
To Held
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatsToPassOn reads the mesh for seats held once whose holder on record has left (novox/hq ADR 0449 §4).
|
|
||||||
//
|
|
||||||
// **A holder on record that is assigned on no machine holds nothing, and a seat that answers nothing is
|
|
||||||
// the day a delivery removed its module.** Of such a seat, exactly one assignment in the mesh able to hold
|
|
||||||
// it (it claims the seat at its scope and CanHold accepts it) takes it over: that is a pass, and the caller
|
|
||||||
// records it and writes the hand act. With no such assignment the record stays; with two or more nothing is
|
|
||||||
// recorded and the answer says so, naming both and the `seat --to` that settles it. A holder still assigned
|
|
||||||
// anywhere changes nothing: the other claimants stand beside it, eligible and silent (ADR 0131). Only mesh
|
|
||||||
// seats held once are read: a replicated seat's holders are added by an act (ADR 0223), a kinded bench is held
|
|
||||||
// by its claims, and a seat with several holders on record is a store that disagrees with the mesh (refused
|
|
||||||
// by the resolver).
|
|
||||||
func SeatsToPassOn(holdings []Held, assignments []Assignment) ([]SeatPass, []string) {
|
|
||||||
assigned := map[string]bool{}
|
|
||||||
for _, a := range assignments {
|
|
||||||
assigned[a.Manifest.Module] = true
|
|
||||||
}
|
|
||||||
bySeat := map[string][]Held{}
|
|
||||||
var order []string
|
|
||||||
for _, h := range holdings {
|
|
||||||
s, ok := SeatNamed(h.Claim)
|
|
||||||
if !ok || s.Replicated || s.Scope != ScopeMesh || h.Scope != ScopeMesh || KindedBenches[s.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, seen := bySeat[s.Name]; !seen {
|
|
||||||
order = append(order, s.Name)
|
|
||||||
}
|
|
||||||
bySeat[s.Name] = append(bySeat[s.Name], h)
|
|
||||||
}
|
|
||||||
sort.Strings(order)
|
|
||||||
|
|
||||||
var passes []SeatPass
|
|
||||||
var said []string
|
|
||||||
for _, name := range order {
|
|
||||||
recs := bySeat[name]
|
|
||||||
if len(recs) != 1 || assigned[recs[0].Module] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seat, _ := SeatNamed(name)
|
|
||||||
var claimants []Held
|
|
||||||
for _, a := range assignments {
|
|
||||||
if CanHold(a.Manifest, seat) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
claimants = append(claimants, Held{Claim: seat.Name, Scope: seat.Scope, Node: a.Node, Module: a.Manifest.Module})
|
|
||||||
}
|
|
||||||
sort.Slice(claimants, func(i, j int) bool {
|
|
||||||
if claimants[i].Node != claimants[j].Node {
|
|
||||||
return claimants[i].Node < claimants[j].Node
|
|
||||||
}
|
|
||||||
return claimants[i].Module < claimants[j].Module
|
|
||||||
})
|
|
||||||
switch len(claimants) {
|
|
||||||
case 0:
|
|
||||||
case 1:
|
|
||||||
passes = append(passes, SeatPass{Seat: seat, From: recs[0], To: claimants[0]})
|
|
||||||
default:
|
|
||||||
said = append(said, fmt.Sprintf(
|
|
||||||
"%s on %s is on record as the holder of %q and is assigned nowhere, and %s on %s and %s on %s both claim it "+
|
|
||||||
"— nothing is recorded; `seat %s --to <node>/<module>` records the one that holds it",
|
|
||||||
recs[0].Module, recs[0].Node, seat.Name, claimants[0].Module, claimants[0].Node,
|
|
||||||
claimants[1].Module, claimants[1].Node, seat.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return passes, said
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The paths a seat's holder owns (novox/hq ADR 0287).
|
|
||||||
//
|
|
||||||
// **Who owns a machine-wide file is the role's question, answered once.** Until this, which module owned
|
|
||||||
// /etc/hosts or /etc/resolv.conf was decided record by record (ADRs 0199, 0222, 0223, 0241) and enforced
|
|
||||||
// only after the fact, by checkResources finding two modules on one machine declaring the same path: a
|
|
||||||
// module that did not hold node-hostname could declare /etc/hosts on a machine where nobody held it, and
|
|
||||||
// nothing refused it. Now a seat's definition names the paths the role owns on any machine, and three
|
|
||||||
// refusals follow from it, each where the mesh can still say no:
|
|
||||||
//
|
|
||||||
// - a claim whose module does not declare every path its seat names, by CanHold, at registration and at
|
|
||||||
// handover;
|
|
||||||
// - a module declaring a path a seat names while claiming none of the seats that name it, at
|
|
||||||
// registration (CatalogueProblems), and what the controller generates for a machine, at composition;
|
|
||||||
// - a module's own seat naming a path another seat already names, at registration — the second loses.
|
|
||||||
//
|
|
||||||
// Reading a seat's path is access, never a resource, so it stays allowed.
|
|
||||||
|
|
||||||
// SeatPath is one path a seat's holder owns: absolute, literal, on the machine where the holder runs.
|
|
||||||
type SeatPath struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
// StepsBackFor is a node seat whose holder owns the path instead, on a machine where that seat is held
|
|
||||||
// (novox/hq ADR 0287, ADR 0247): this seat's holder still declares the path, and its declaration is not
|
|
||||||
// composed there. Only the mesh's own seats step back, each by a record.
|
|
||||||
StepsBackFor string `json:"steps-back-for,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally
|
|
||||||
// with no placeholder, and clean, so it compares with what a module declares byte for byte.
|
|
||||||
func seatPathProblem(seat, scope, p string) string {
|
|
||||||
switch {
|
|
||||||
case scope != ScopeNode:
|
|
||||||
return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+
|
|
||||||
"because a path is a machine's (novox/hq ADR 0287)", seat, p, scope)
|
|
||||||
case strings.Contains(p, "${"):
|
|
||||||
return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+
|
|
||||||
"literally (novox/hq ADR 0287)", seat, p)
|
|
||||||
case !strings.HasPrefix(p, "/") || path.Clean(p) != p:
|
|
||||||
return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)",
|
|
||||||
seat, p)
|
|
||||||
case path.Dir(p) == "/":
|
|
||||||
// The root or a top-level directory is every role's, never one role's own (ADR 0287).
|
|
||||||
return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+
|
|
||||||
"role owns (novox/hq ADR 0287)", seat, p)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR
|
|
||||||
// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it
|
|
||||||
// without stepping back; and a seat steps back only for another node seat naming the same path without
|
|
||||||
// stepping back, so the stepping back cannot cycle.
|
|
||||||
func SeatPathProblems(set []Seat) []string {
|
|
||||||
var problems []string
|
|
||||||
owners := map[string][]string{} // path → the seats naming it without stepping back
|
|
||||||
stepping := map[string][]SeatPath{} // seat → the paths it names stepping back
|
|
||||||
byName := map[string]Seat{}
|
|
||||||
for _, s := range set {
|
|
||||||
byName[s.Name] = s
|
|
||||||
for _, p := range s.Paths {
|
|
||||||
if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" {
|
|
||||||
problems = append(problems, why)
|
|
||||||
}
|
|
||||||
if p.StepsBackFor == "" {
|
|
||||||
owners[p.Path] = append(owners[p.Path], s.Name)
|
|
||||||
} else {
|
|
||||||
stepping[s.Name] = append(stepping[s.Name], p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range set {
|
|
||||||
for _, p := range stepping[s.Name] {
|
|
||||||
other, known := byName[p.StepsBackFor]
|
|
||||||
switch {
|
|
||||||
case !known || other.Scope != ScopeNode:
|
|
||||||
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+
|
|
||||||
"node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor))
|
|
||||||
case !namesWithoutSteppingBack(other, p.Path):
|
|
||||||
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+
|
|
||||||
"own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)",
|
|
||||||
s.Name, p.Path, other.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, p := range sortedKeys(owners) {
|
|
||||||
if len(owners[p]) > 1 {
|
|
||||||
problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+
|
|
||||||
"no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range set {
|
|
||||||
for _, p := range stepping[s.Name] {
|
|
||||||
if len(owners[p.Path]) == 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+
|
|
||||||
"(novox/hq ADR 0287)", p.Path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesWithoutSteppingBack(s Seat, p string) bool {
|
|
||||||
for _, sp := range s.Paths {
|
|
||||||
if sp.Path == p && sp.StepsBackFor == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file
|
|
||||||
// the mesh renders for it outside the operator's home. An access is not here — reading is not owning.
|
|
||||||
//
|
|
||||||
// **Cleaned, because the machine writes the cleaned path**: /etc//hosts, /etc/./hosts, /etc/../etc/hosts and
|
|
||||||
// /etc/hosts/ are all /etc/hosts to it, and a seat's path is clean by its own rule, so a declaration compared
|
|
||||||
// as written would pass for a path nobody named.
|
|
||||||
func declaredPaths(m Manifest) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
|
||||||
out[path.Clean(p)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, f := range m.Facts {
|
|
||||||
if !f.Home && strings.HasPrefix(f.Path, "/") {
|
|
||||||
out[path.Clean(f.Path)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// declaresPath says whether a module declares one path, as a resource or as a file the mesh renders.
|
|
||||||
func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] }
|
|
||||||
|
|
||||||
// missingPaths is each path a seat names that a module does not declare, in the seat's order.
|
|
||||||
func missingPaths(m Manifest, paths []SeatPath) []string {
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
has := declaredPaths(m)
|
|
||||||
var missing []string
|
|
||||||
for _, p := range paths {
|
|
||||||
if !has[p.Path] {
|
|
||||||
missing = append(missing, p.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathSeats is which seats name each path: the mesh's own, and then those modules declare, in the order
|
|
||||||
// given. A module's seat naming a path a seat already names loses it, and the refusal says so (novox/hq
|
|
||||||
// ADR 0287 — as for a seat's name, the second declaration loses).
|
|
||||||
func pathSeats(order []string, shelf Shelf, declaredBy map[string]string) (map[string][]string, []string) {
|
|
||||||
named := map[string][]string{}
|
|
||||||
for _, s := range Seats() {
|
|
||||||
for _, p := range s.Paths {
|
|
||||||
named[p.Path] = append(named[p.Path], s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, module := range order {
|
|
||||||
for _, s := range shelf[module].DefinesSeats {
|
|
||||||
if declaredBy != nil && declaredBy[s.Name] != module {
|
|
||||||
continue // a second declaration of the seat's name, refused for that already
|
|
||||||
}
|
|
||||||
for _, p := range s.Paths {
|
|
||||||
if others := named[p]; len(others) > 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares the seat %s naming the path %q, which %s already names; no two seats "+
|
|
||||||
"name one path (novox/hq ADR 0287)", module, s.Name, p, strings.Join(others, " and ")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
named[p] = append(named[p], s.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return named, problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// unclaimedSeatPaths is each path a module declares that a seat names while it claims none of the seats
|
|
||||||
// naming it (novox/hq ADR 0287): only a claimant declares a seat's path, and a module claiming any one of
|
|
||||||
// them may.
|
|
||||||
func unclaimedSeatPaths(m Manifest, named map[string][]string) []string {
|
|
||||||
claims := map[string]bool{}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
claims[canonicalSeat(c.Name)] = true
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
declared := declaredPaths(m)
|
|
||||||
for _, p := range sortedKeys(declared) {
|
|
||||||
seats := named[p]
|
|
||||||
if len(seats) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
claimsOne := false
|
|
||||||
for _, s := range seats {
|
|
||||||
claimsOne = claimsOne || claims[s]
|
|
||||||
}
|
|
||||||
if !claimsOne {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares the path %q, which the seat %s names, and claims none of them; only a holder of "+
|
|
||||||
"a seat naming a path declares it (novox/hq ADR 0287)", m.Module, p, strings.Join(seats, " and ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path
|
|
||||||
// only on a node seat, absolute, literal and clean.
|
|
||||||
func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, p := range s.Paths {
|
|
||||||
if why := seatPathProblem(s.Name, s.At(), p); why != "" {
|
|
||||||
problems = append(problems, m.Module+": "+why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat
|
|
||||||
// never steps back: only the mesh's own seats do, each by a record.
|
|
||||||
func asSeatPaths(paths []string) []SeatPath {
|
|
||||||
out := make([]SeatPath, 0, len(paths))
|
|
||||||
for _, p := range paths {
|
|
||||||
out = append(out, SeatPath{Path: p})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming
|
|
||||||
// the path, and that seat's holder there, with the stepping back applied.
|
|
||||||
type PathOwner struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PathOwners is, for every path a seat in the set names and every machine where a seat naming it is held,
|
|
||||||
// who owns the path there. A seat that steps back for another held on the same machine is passed over.
|
|
||||||
func PathOwners(set []Seat, held []Held) []PathOwner {
|
|
||||||
// node → seat → holding module
|
|
||||||
holders := map[string]map[string]string{}
|
|
||||||
for _, h := range held {
|
|
||||||
s, ok := SeatNamed(h.Claim)
|
|
||||||
if !ok || h.Scope != ScopeNode || s.Scope != ScopeNode {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if holders[h.Node] == nil {
|
|
||||||
holders[h.Node] = map[string]string{}
|
|
||||||
}
|
|
||||||
if _, taken := holders[h.Node][s.Name]; !taken {
|
|
||||||
holders[h.Node][s.Name] = h.Module
|
|
||||||
}
|
|
||||||
}
|
|
||||||
naming := map[string][]Seat{} // path → the seats naming it
|
|
||||||
steps := map[string]map[string]string{} // path → seat → the seat it steps back for
|
|
||||||
for _, s := range set {
|
|
||||||
for _, p := range s.Paths {
|
|
||||||
naming[p.Path] = append(naming[p.Path], s)
|
|
||||||
if p.StepsBackFor != "" {
|
|
||||||
if steps[p.Path] == nil {
|
|
||||||
steps[p.Path] = map[string]string{}
|
|
||||||
}
|
|
||||||
steps[p.Path][s.Name] = p.StepsBackFor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var out []PathOwner
|
|
||||||
for _, p := range sortedKeys(naming) {
|
|
||||||
for _, node := range sortedKeys(holders) {
|
|
||||||
for _, s := range naming[p] {
|
|
||||||
module, holds := holders[node][s.Name]
|
|
||||||
if !holds {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if forSeat := steps[p][s.Name]; forSeat != "" {
|
|
||||||
if _, there := holders[node][forSeat]; there {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, PathOwner{Path: p, Node: node, Seat: s.Name, Module: module})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatPathsOnMachine is each path the modules of one machine declare that a seat names, for a module
|
|
||||||
// claiming none of the seats naming it (novox/hq ADR 0287). Registration refuses the same of a manifest;
|
|
||||||
// this holds what the controller generates for a machine to it too, because a computed module's resources
|
|
||||||
// exist only once composed (checkResources is asked them there). The seats are the mesh's own and those the
|
|
||||||
// machine's modules declare.
|
|
||||||
func seatPathsOnMachine(modules []Manifest) []string {
|
|
||||||
shelf := Shelf{}
|
|
||||||
var order []string
|
|
||||||
for _, m := range modules {
|
|
||||||
shelf[m.Module] = m
|
|
||||||
order = append(order, m.Module)
|
|
||||||
}
|
|
||||||
named, _ := pathSeats(order, shelf, nil)
|
|
||||||
var problems []string
|
|
||||||
for _, m := range modules {
|
|
||||||
problems = append(problems, unclaimedSeatPaths(m, named)...)
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,951 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The seats a mesh can have (novox/hq ADR 0110).
|
|
||||||
//
|
|
||||||
// **A closed set, defined here rather than by whoever claims one.** Until this, a well-formed name
|
|
||||||
// became a seat by being claimed, so nothing could say which seats a mesh has or who fills them:
|
|
||||||
// `the-showcase` and `the-build-machine` were each invented by the module claiming it. The set is
|
|
||||||
// what a person reads to learn what a mesh can have, so an entry nobody argued for is an entry
|
|
||||||
// nobody can explain — the same reason every shape in the host's vocabulary names its decision.
|
|
||||||
//
|
|
||||||
// A seat is held by a module assignment. What the mesh knows about a holder is what it knows about
|
|
||||||
// that assignment; nothing about holders is kept here or anywhere else.
|
|
||||||
|
|
||||||
// Seat is one role the mesh defines.
|
|
||||||
type Seat struct {
|
|
||||||
// Name is what a manifest claims.
|
|
||||||
Name string
|
|
||||||
// Scope is where there may be only one holder — unless the seat is Replicated.
|
|
||||||
Scope string
|
|
||||||
// Replicated says a mesh seat may be held on several machines at once, each holder answering the
|
|
||||||
// same thing (novox/hq ADR 0223): the mesh's resolver, held on the anchor and the home server so a
|
|
||||||
// machine's resolver file lists two that give one answer. Each holder is on record, added by an
|
|
||||||
// act (`seat <name> --add <node>/<module>`), never by being assigned: two claimants with nothing on
|
|
||||||
// record are refused exactly as for any mesh seat. One per machine still — two modules on one
|
|
||||||
// node claiming it are refused. Compiled, never stored, like Receives: it is the mesh's definition of
|
|
||||||
// the role, and the store's rows carry no column for it.
|
|
||||||
Replicated bool
|
|
||||||
// Delivers is the provision the seat's holder answers for, or empty. A seat that delivers a
|
|
||||||
// provision may only be held by a module providing it at the seat's scope, and its holder is
|
|
||||||
// what a requirement for that provision resolves to when several modules provide it.
|
|
||||||
Delivers string
|
|
||||||
// Accepts, Emits and Serves are the protocol of the role, as local verbs — the same three a
|
|
||||||
// module declares for a seat of its own (novox/hq ADR 0118), and empty for most of these: a seat
|
|
||||||
// is usually about who does a job and not about what may be said to them.
|
|
||||||
//
|
|
||||||
// **Named here so the mesh has no role it cannot describe** (ADR 0121). Without them a build
|
|
||||||
// machine had three audiences for one outcome and nothing derived a grant for any of them, and an
|
|
||||||
// event about a role had nowhere to live but the namespace of whichever module held that role
|
|
||||||
// today — which the bus refuses, because a namespace belongs to who it is named for.
|
|
||||||
Accepts []string
|
|
||||||
Emits []string
|
|
||||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
|
||||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
|
||||||
Serves []Verb
|
|
||||||
// Receives is what other modules may contribute to the seat's holder, by kind (novox/hq ADR
|
|
||||||
// 0212): each kind is text in the tool's own grammar, placed by the holder with
|
|
||||||
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
|
|
||||||
// definition of the role, and the store's rows carry no column for it.
|
|
||||||
Receives []Receivable
|
|
||||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287): a holder declares
|
|
||||||
// every one, and a module claiming no seat that names a path may not declare it. Only a node seat
|
|
||||||
// names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision,
|
|
||||||
// recorded, and the store's rows carry no column for it.
|
|
||||||
Paths []SeatPath
|
|
||||||
// Decision is the record that made it a seat.
|
|
||||||
Decision string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receivable is one kind of contribution a seat receives (novox/hq ADR 0212 §2): its name, and the
|
|
||||||
// comment prefix of the tool's grammar, with which the controller names each contributing module.
|
|
||||||
type Receivable struct {
|
|
||||||
Kind string
|
|
||||||
Comment string
|
|
||||||
// Dirs says a contribution of this kind may name its contributor's own directories as
|
|
||||||
// `${dir:<id>}`, filled with where they are on the machine before the holder places it (novox/hq
|
|
||||||
// to-be 43): a module saying which of its data to back up names a directory the mesh placed, and
|
|
||||||
// only the mesh knows where. Off for every kind written in a tool's grammar that has its own
|
|
||||||
// `${…}` — a shell's — where the mesh filling one would change what the tool reads.
|
|
||||||
Dirs bool
|
|
||||||
// Shape, when set, makes the kind data rather than text (novox/hq ADR 0255): a contributor gives
|
|
||||||
// `data` in this shape, and the holder renders each piece with the template its claim gives.
|
|
||||||
Shape *Shape
|
|
||||||
// Offered says a contribution of this kind does not depend on the seat (novox/hq ADR 0255): where
|
|
||||||
// nobody holds it nothing is placed, and the contributor runs there all the same. A battery's bar
|
|
||||||
// block is offered — the power module is on every machine, and a server has no bar.
|
|
||||||
Offered bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
|
|
||||||
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
|
|
||||||
// written; the store's table is seeded from it and thereafter is the live, editable copy.
|
|
||||||
//
|
|
||||||
// In the order a person reads it: the mesh's own, then a node's.
|
|
||||||
var defaultSeats = append([]Seat{
|
|
||||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
|
||||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
|
||||||
// so no work queue is raised for it — only what its holder may say.
|
|
||||||
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
|
|
||||||
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
|
|
||||||
// the control plane is replaced.
|
|
||||||
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
|
||||||
// And what is wrong, as it changes, and the self-check's heartbeat (novox/hq to-be 45 §2, §4).
|
|
||||||
Emits: []string{"applied", "refused", "built-before",
|
|
||||||
"condition-raised", "condition-changed", "condition-cleared", "doctor-heartbeat",
|
|
||||||
// A value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
|
|
||||||
"secret-replaced",
|
|
||||||
// Every act a healer takes (novox/hq to-be 45 §7).
|
|
||||||
"healer-acted",
|
|
||||||
// A build put back after its gate failed (novox/hq ADR 0236, to-be 45 §8).
|
|
||||||
"rolled-back",
|
|
||||||
// A pull request's merge check, judged (novox/hq to-be 45 §9).
|
|
||||||
"checked",
|
|
||||||
// A walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
|
||||||
"plan-moved",
|
|
||||||
// A tier sent to a machine, once the send is kept (novox/hq ADR 0406 §5, issue 974).
|
|
||||||
"sent",
|
|
||||||
// A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
|
||||||
"assignment-changed", "declared-config-changed", "predefined-changed"},
|
|
||||||
Serves: ControllerVerbs},
|
|
||||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
|
||||||
// served by whichever module holds the seat with tools of these names.
|
|
||||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "databases", Description: "Every database the store holds, with its on-disk size.",
|
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}},
|
|
||||||
{Name: "query", Description: "One read-only statement against one database the store holds.",
|
|
||||||
Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"},
|
|
||||||
[]string{"database", "sql"}), Replaces: []string{"psql"}},
|
|
||||||
}},
|
|
||||||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
|
||||||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
|
||||||
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
|
||||||
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
|
||||||
// connection would mint a credential for each.
|
|
||||||
{Name: BrokerSeat, Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
|
||||||
// The vault: the controller seals every minted credential with what it provides, which is the
|
|
||||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
|
||||||
// second claimant, refused by name, rather than a candidate for a pin.
|
|
||||||
//
|
|
||||||
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
|
|
||||||
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
|
|
||||||
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
|
|
||||||
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
|
|
||||||
// it. All optional until the vault that runs serves them (design 33 §7).
|
|
||||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
|
||||||
"sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " +
|
|
||||||
"bus proved it. Never the value in the clear, and never to a private key.",
|
|
||||||
Input: schema(map[string]string{"name": "the secret's name",
|
|
||||||
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
|
||||||
[]string{"name", "public_key"})},
|
|
||||||
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
|
|
||||||
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
|
|
||||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
|
|
||||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
|
||||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
|
||||||
"by": "who gave it at the terminal",
|
|
||||||
"origin": "issued for a bus account the controller minted for a core or moved module and hands " +
|
|
||||||
"at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)},
|
|
||||||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
|
||||||
"from the controller alone: the ask is recorded in the vault's store and bounded before the prompt " +
|
|
||||||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
|
||||||
"module": "the module", "name": "the secret's name in the module",
|
|
||||||
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
|
|
||||||
"outcome": "instead of sealed: how the prompt ended"}), nil)},
|
|
||||||
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
|
|
||||||
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
|
|
||||||
"the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " +
|
|
||||||
"each as that holder's own; the same value again writes nothing.",
|
|
||||||
Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision",
|
|
||||||
"provider": "the provider's machine", "module": "the provider's module"}), nil))},
|
|
||||||
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
|
|
||||||
"software issued and for an accepted credential. Never answers the value.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
|
|
||||||
"name": "the secret's name in the module", "provider": "the provider's machine",
|
|
||||||
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
|
|
||||||
// The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what
|
|
||||||
// was sealed to its machine, which the controller cannot open; the controller copies its record of asks.
|
|
||||||
{Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " +
|
|
||||||
"from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " +
|
|
||||||
"and generation and get a call and a key made for it, then send the call, the value sealed to that " +
|
|
||||||
"key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " +
|
|
||||||
"back sealed to that key.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine",
|
|
||||||
"module": "its module", "name": "its name in the module",
|
|
||||||
"provider": "a pair credential: the provider's machine", "provision": "the provision",
|
|
||||||
"consumer": "the consumer's machine", "consumer_module": "the consuming module",
|
|
||||||
"local": "the credential's local name, when it has one",
|
|
||||||
"origin": "made, accepted or issued, as the controller's store says",
|
|
||||||
"generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)",
|
|
||||||
"read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)},
|
|
||||||
// A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786).
|
|
||||||
{Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " +
|
|
||||||
"own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " +
|
|
||||||
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
|
|
||||||
"again changes nothing.",
|
|
||||||
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
|
|
||||||
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
|
|
||||||
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
|
|
||||||
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
|
|
||||||
"type": "array", "items": map[string]any{"type": "object"},
|
|
||||||
"description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}},
|
|
||||||
}},
|
|
||||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
|
||||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
|
||||||
// Optional until the mesh-config that runs serves it (design 33 §7).
|
|
||||||
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
|
||||||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
|
||||||
"with why, validated, and naming each module on each node whose value moves. A trusted value only " +
|
|
||||||
"from the controller's terminal.",
|
|
||||||
Input: configInput()},
|
|
||||||
}},
|
|
||||||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
|
||||||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
|
||||||
// which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads
|
|
||||||
// them. Optional until the mesh-provisioner that runs serves it (design 33 §7).
|
|
||||||
{Name: "mesh-provisioner", Scope: ScopeMesh, Decision: "novox/hq ADR 0349",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " +
|
|
||||||
"(which domain answers which kind of placeholder), the needs each module on each node recorded, what " +
|
|
||||||
"is applied where, or the items of every collection, narrowed by node and module. Reads only.",
|
|
||||||
Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect",
|
|
||||||
"node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)},
|
|
||||||
}},
|
|
||||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
|
||||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
|
||||||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
|
||||||
// image registry.
|
|
||||||
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
|
||||||
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
|
|
||||||
// The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every
|
|
||||||
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
|
||||||
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
|
||||||
{Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
|
|
||||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
|
||||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
|
||||||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
|
||||||
{Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
|
|
||||||
{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
|
|
||||||
// A build is work submitted to this role and its outcome is the role's own event (ADR 0129).
|
|
||||||
// One publish reaches whoever asked, the controller that records it, and the catalogue that
|
|
||||||
// places it in the graph — what the old bus's shared exchange did for free.
|
|
||||||
// A build says what it does as it does it (novox/hq ADR 0157): `started` when work is taken,
|
|
||||||
// `log.<build id>` for every line, `built` for the outcome. The log's tail token is the build's
|
|
||||||
// id, so a reader follows one build by subject alone.
|
|
||||||
// **Node-scoped, and every holder takes from one queue** (novox/hq ADR 0190): a build is asked of
|
|
||||||
// the role, and whichever machine holding the seat is idle pulls it. One holder per machine is
|
|
||||||
// what the scope says; sharing the work is what a seat's queue has always done.
|
|
||||||
//
|
|
||||||
// **And its holder answers for the build it is running** (novox/hq ADR 0219): what it is
|
|
||||||
// building, kill it, take nothing new, take again. The queue as a whole is the controller's to
|
|
||||||
// show and change (`queue`, `cancel`, `clear`); what one machine does with an ask it already
|
|
||||||
// took only that machine can do. `paused.<node>` is each holder saying whether it takes work, so
|
|
||||||
// the controller can tell a plan waiting on a paused seat from one that is late.
|
|
||||||
{Name: "node-build-agent", Scope: ScopeNode,
|
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*", "paused.*"},
|
|
||||||
Serves: buildAgentVerbs(),
|
|
||||||
Decision: "novox/hq ADR 0190, ADR 0219"},
|
|
||||||
// **Retired by ADR 0190, kept while a manifest still claims it.** The one build machine's seat.
|
|
||||||
// A claim to a seat the mesh no longer defines is refused, and the module holding this one is
|
|
||||||
// assigned on a live machine until build-agent replaces it — removing the row first would make
|
|
||||||
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
|
|
||||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
|
|
||||||
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
|
|
||||||
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
|
|
||||||
// than one machine, each answering the same names from the same roster, and every machine's
|
|
||||||
// resolver file lists every holder — its own first — and no public resolver, so whichever answers
|
|
||||||
// first gives the one answer. Delivers what a machine's resolver configuration requires, so that
|
|
||||||
// requirement resolves to a holder wherever they are placed.
|
|
||||||
{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Replicated: true,
|
|
||||||
Decision: "novox/hq ADR 0194, ADR 0223"},
|
|
||||||
// **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes
|
|
||||||
// /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts
|
|
||||||
// file as the operator's, changed through these three verbs on that machine alone. The controller
|
|
||||||
// holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an
|
|
||||||
// alias on a mesh that knew it.
|
|
||||||
{Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223",
|
|
||||||
// The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287).
|
|
||||||
Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
|
||||||
"the operator's, or the block of the module or tool that writes it.",
|
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}},
|
|
||||||
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
|
|
||||||
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
|
|
||||||
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
|
|
||||||
"names": "the names for it, separated by spaces"}, []string{"address", "names"}),
|
|
||||||
Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}},
|
|
||||||
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
|
|
||||||
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
|
||||||
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
|
|
||||||
[]string{"name"}), Replaces: []string{"sed -i /etc/hosts"}},
|
|
||||||
}},
|
|
||||||
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
|
||||||
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
|
||||||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
|
||||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
|
||||||
"holding now, and the totals since the jail started; one jail's detail when named.",
|
|
||||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}},
|
|
||||||
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
|
||||||
"that holds it and when the ban ends.",
|
|
||||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}},
|
|
||||||
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
|
||||||
"operator's act on the live ban list, which the mesh never writes itself.",
|
|
||||||
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}),
|
|
||||||
Replaces: []string{"fail2ban-client set banip"}},
|
|
||||||
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
|
||||||
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}),
|
|
||||||
Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}},
|
|
||||||
}},
|
|
||||||
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
|
||||||
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
|
||||||
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
|
||||||
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " +
|
|
||||||
"ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " +
|
|
||||||
"chain when asked.",
|
|
||||||
Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)",
|
|
||||||
"chain": "one chain of that table (optional)"}, nil),
|
|
||||||
Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}},
|
|
||||||
{Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " +
|
|
||||||
"and answer with the mesh's table as loaded.",
|
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}},
|
|
||||||
{Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " +
|
|
||||||
"host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " +
|
|
||||||
"DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " +
|
|
||||||
"active found firewall's chains. An operator's act, by name, never a flush.",
|
|
||||||
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}),
|
|
||||||
Replaces: []string{"nft delete", "iptables -X"}},
|
|
||||||
}},
|
|
||||||
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
|
||||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
|
||||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
|
||||||
// served by the node tools runtime (ADR 0175).
|
|
||||||
{Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
|
||||||
Serves: serviceManagerVerbs()},
|
|
||||||
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
|
|
||||||
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
|
|
||||||
// mesh's word for "something on this machine answers for installing", where a capability only
|
|
||||||
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
|
|
||||||
// of it is decided when someone needs to ask.
|
|
||||||
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
|
|
||||||
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
|
|
||||||
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
|
|
||||||
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
|
|
||||||
// dependency has a seat to name and the runtime's module has one to claim.
|
|
||||||
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
|
|
||||||
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
|
|
||||||
// every module contributes to it. No verbs — the seat says who places the environment's files,
|
|
||||||
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
|
|
||||||
// which module wrote it.
|
|
||||||
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
|
|
||||||
// The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a
|
|
||||||
// bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the
|
|
||||||
// machine's templates. It serves no verb an agent calls.
|
|
||||||
{Name: "node-provisioner", Scope: ScopeNode, Decision: "novox/hq ADR 0349"},
|
|
||||||
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
|
|
||||||
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
|
|
||||||
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
|
|
||||||
// may call where the machine serves it (optional: ADR 0268); the holder places every module's shell
|
|
||||||
// code in its slots.
|
|
||||||
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
|
||||||
Serves: loginShellVerbs()},
|
|
||||||
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
|
|
||||||
// code modules contribute for the power moments, and publishes the machine's power states as
|
|
||||||
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
|
|
||||||
{Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"},
|
|
||||||
// The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager
|
|
||||||
// does not — a laptop's vendor keys — run by one module per machine, which owns its
|
|
||||||
// configuration. Every other module with keys contributes trigger lines to it.
|
|
||||||
{Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212",
|
|
||||||
Receives: []Receivable{{Kind: "trigger", Comment: "#"}}},
|
|
||||||
// The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its
|
|
||||||
// system service, never restarts it live, and publishes curated events about it, never its traffic.
|
|
||||||
// It receives nothing yet: packages ship their own policies.
|
|
||||||
{Name: MessageBusSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0215"},
|
|
||||||
// The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of
|
|
||||||
// the data every module on the machine declares, on the machine, against mistakes rather than
|
|
||||||
// disasters. A module contributes `backup` lines — what to run to take a consistent copy, and
|
|
||||||
// which of its directories to keep.
|
|
||||||
{Name: BackupSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0214",
|
|
||||||
Serves: backupVerbs(),
|
|
||||||
// `backup` is what to run and which paths to keep; `data` every item a module declares, with its
|
|
||||||
// class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections.
|
|
||||||
Receives: []Receivable{{Kind: BackupKindBackup, Comment: "#", Dirs: true}, {Kind: BackupKindData, Comment: "#", Dirs: true}}},
|
|
||||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
|
||||||
// model change, not a rename, so it stays until that is built.
|
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
|
||||||
// The program that manages the machine's own network. It delivers nothing: its holder keeps the
|
|
||||||
// manager and the mesh from contradicting each other — the private network's interface left
|
|
||||||
// alone — and writes the machine's resolver file itself, because the manager is what would
|
|
||||||
// otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat).
|
|
||||||
// It never declares a link, an address or a wireless network, because the link is the only
|
|
||||||
// channel a fix could arrive on. A seat
|
|
||||||
// rather than a condition in the resolver's module, so a machine running two managers is
|
|
||||||
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
|
||||||
//
|
|
||||||
// Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the
|
|
||||||
// resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers
|
|
||||||
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
|
|
||||||
// the same way whatever manages its network. Optional until both holders serve them.
|
|
||||||
//
|
|
||||||
// It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287,
|
|
||||||
// ADR 0247).
|
|
||||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
|
|
||||||
Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}},
|
|
||||||
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
|
|
||||||
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
|
|
||||||
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
|
|
||||||
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
|
|
||||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
|
|
||||||
Paths: []SeatPath{{Path: ResolverFile}}},
|
|
||||||
// A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server
|
|
||||||
// exports a machine's folders to the private network and provides each as `nfs-share`; the holder of
|
|
||||||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
|
||||||
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
|
||||||
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
|
||||||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
|
||||||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
|
||||||
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
|
||||||
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
|
|
||||||
// on every node, then node-reads goes (ADR 0394 §9).
|
|
||||||
{Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
|
|
||||||
},
|
|
||||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
|
||||||
graphicalSessionSeats()...)
|
|
||||||
|
|
||||||
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
|
||||||
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
|
||||||
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this
|
|
||||||
// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
|
|
||||||
// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
|
|
||||||
// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it.
|
|
||||||
func isSystemSeatName(name string) bool {
|
|
||||||
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
|
|
||||||
}
|
|
||||||
|
|
||||||
// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by
|
|
||||||
// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a
|
|
||||||
// change to data, not to this code.
|
|
||||||
var seats = defaultSeats
|
|
||||||
|
|
||||||
// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its
|
|
||||||
// text), and a limit that is a number; every other argument a string.
|
|
||||||
func configInput() map[string]any {
|
|
||||||
in := schema(map[string]string{"action": "list (when not given), set, clear or history",
|
|
||||||
"key": "the config key", "value": "for set: the value, of the key's type",
|
|
||||||
"module": "a module: its own value, with node", "node": "a node",
|
|
||||||
"why": "for set and clear: why", "by": "who gave the value at the controller's terminal",
|
|
||||||
"limit": "for history: at most this many, newest"}, nil)
|
|
||||||
props := in["properties"].(map[string]any)
|
|
||||||
props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " +
|
|
||||||
"a boolean, or for a trusted key an object or a list"}
|
|
||||||
props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultSeats is the set the mesh ships with, for seeding the store's seat table.
|
|
||||||
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
|
|
||||||
|
|
||||||
// UseSeats replaces the working set with the one the control plane read from its store.
|
|
||||||
//
|
|
||||||
// **Empty is ignored on purpose.** A store that has not been seeded yet — or one that could not be
|
|
||||||
// read — must leave the compiled defaults in force rather than emptying the set: an empty set would
|
|
||||||
// refuse every claim and could stop the control plane composing at all, which is a far worse failure
|
|
||||||
// than running on the set the binary shipped with. So the store can only ever *replace* the set with
|
|
||||||
// a non-empty one, never erase it.
|
|
||||||
func UseSeats(s []Seat) {
|
|
||||||
if len(s) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// **The store's rows carry no protocol yet, and the protocol is what the bus is derived
|
|
||||||
// from.** ADR 0129 gives a seat what it accepts, emits and serves; ADR 0122 moved the set into
|
|
||||||
// a table that has name, scope, delivers and decision and nothing else, and the columns for
|
|
||||||
// the rest are not there yet. So a row replacing a compiled entry would silently drop the
|
|
||||||
// protocol, and the roles' work queues would never be raised — found live as "no response
|
|
||||||
// from stream" the first time a build was submitted over the new bus (2026-09-28). Until the
|
|
||||||
// table gains the columns, a row without a protocol keeps the compiled one of the same name.
|
|
||||||
byName := map[string]Seat{}
|
|
||||||
for _, d := range defaultSeats {
|
|
||||||
byName[d.Name] = d
|
|
||||||
}
|
|
||||||
merged := make([]Seat, 0, len(s))
|
|
||||||
for _, row := range s {
|
|
||||||
if len(row.Accepts)+len(row.Emits)+len(row.Serves) == 0 {
|
|
||||||
if d, known := byName[row.Name]; known {
|
|
||||||
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What a seat receives, whether it is replicated and the paths it names are never stored
|
|
||||||
// (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones.
|
|
||||||
if d, known := byName[row.Name]; known {
|
|
||||||
row.Receives = d.Receives
|
|
||||||
row.Paths = d.Paths
|
|
||||||
row.Replicated = d.Replicated
|
|
||||||
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
|
||||||
}
|
|
||||||
merged = append(merged, row)
|
|
||||||
}
|
|
||||||
seats = merged
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionalAsCompiled is a row's verbs with each one the compiled seat marks optional marked so. The mark
|
|
||||||
// is never stored (Verb.Optional), and a row is what the working set holds once the store is read: a verb
|
|
||||||
// the seeding added to the row — `failed`, `checks` — would otherwise come back required, and every holder
|
|
||||||
// not serving it yet would be refused at registration and at handover, which the mark exists to prevent.
|
|
||||||
//
|
|
||||||
// **And what a verb replaces is the compiled one** (novox/hq ADR 0245): a row seeded before the field
|
|
||||||
// existed carries none, and re-seeding widens a verb's arguments only, so read from the row alone the
|
|
||||||
// `tools` answer — what the mesh MCP server's search and the agent's instructions are built from — would
|
|
||||||
// say no verb replaces anything on a mesh whose rows exist. The mesh's statement of what a role replaces
|
|
||||||
// is this binary's, like the optional mark.
|
|
||||||
func optionalAsCompiled(row, compiled []Verb) []Verb {
|
|
||||||
optional := map[string]bool{}
|
|
||||||
replaces := map[string][]string{}
|
|
||||||
for _, v := range compiled {
|
|
||||||
if v.Optional {
|
|
||||||
optional[v.Name] = true
|
|
||||||
}
|
|
||||||
if len(v.Replaces) > 0 {
|
|
||||||
replaces[v.Name] = v.Replaces
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(optional) == 0 && len(replaces) == 0 {
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
out := make([]Verb, len(row))
|
|
||||||
for i, v := range row {
|
|
||||||
v.Optional = optional[v.Name]
|
|
||||||
if r, ok := replaces[v.Name]; ok {
|
|
||||||
v.Replaces = r
|
|
||||||
}
|
|
||||||
out[i] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from
|
|
||||||
// the store alongside the set, so a reference to a name a seat used to have — a manifest's claim, a
|
|
||||||
// held record — still resolves to it after a rename, and nothing downstream has to change.
|
|
||||||
var aliases = map[string]string{}
|
|
||||||
|
|
||||||
// UseAliases replaces the former-name map with the one the control plane read from its store. Empty
|
|
||||||
// is fine and ordinary: a mesh whose seats have never been renamed has no aliases.
|
|
||||||
func UseAliases(m map[string]string) { aliases = m }
|
|
||||||
|
|
||||||
// Seats is every seat the mesh defines, in reading order.
|
|
||||||
func Seats() []Seat {
|
|
||||||
return append([]Seat(nil), seats...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatNamed is the seat a name refers to, whether that is its current name or one it used to have
|
|
||||||
// (novox/hq ADR 0122). A former name resolves to the seat's canonical row, so a rename breaks no
|
|
||||||
// reference to the old name.
|
|
||||||
func SeatNamed(name string) (Seat, bool) {
|
|
||||||
for _, s := range seats {
|
|
||||||
if s.Name == name {
|
|
||||||
return s, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if canonical, aliased := aliases[name]; aliased {
|
|
||||||
for _, s := range seats {
|
|
||||||
if s.Name == canonical {
|
|
||||||
return s, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Seat{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatDelivering is the seat whose holder answers for a provision, if there is one.
|
|
||||||
func SeatDelivering(provision string) (Seat, bool) {
|
|
||||||
if provision == "" {
|
|
||||||
return Seat{}, false
|
|
||||||
}
|
|
||||||
for _, s := range seats {
|
|
||||||
if s.Delivers == provision {
|
|
||||||
return s, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Seat{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimProblems is what is wrong with a manifest's claims and the seats it defines.
|
|
||||||
//
|
|
||||||
// A claim is one of three things (novox/hq ADR 0121): a **system seat** the control plane defines —
|
|
||||||
// checked for scope and, if it delivers a provision, that the claimant provides it; a **system name
|
|
||||||
// the mesh does not define** (`mesh-*`/`node-*`) — refused, because that namespace is the control
|
|
||||||
// plane's; or a **module-defined seat** — valid only when this manifest also declares it, since a
|
|
||||||
// module may coordinate its own instances through a seat of its own but may not invent one by
|
|
||||||
// claiming it. A module's own seat declaration may not sit in the system namespace or shadow a
|
|
||||||
// system seat.
|
|
||||||
func claimProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
|
|
||||||
defined := map[string]SeatDeclaration{}
|
|
||||||
for _, d := range m.DefinesSeats {
|
|
||||||
if _, isSystem := SeatNamed(d.Name); isSystem || isSystemSeatName(d.Name) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s defines a seat %q in the mesh's own namespace; a module's seat is named outside "+
|
|
||||||
"mesh-*/node-* (novox/hq ADR 0121)", m.Module, d.Name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
defined[d.Name] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if _, known := SeatNamed(c.Name); known {
|
|
||||||
// **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122).
|
|
||||||
// This function runs wherever a manifest is parsed, and one of those places is the
|
|
||||||
// build machine, which has no store: there, `SeatNamed` answers from the set the
|
|
||||||
// binary shipped with, so a build would be refused for disagreeing with a compiled
|
|
||||||
// copy of data the control plane owns. Exactly that happened — a holder of the bus
|
|
||||||
// seat was refused for not providing what a stale compiled row said the seat
|
|
||||||
// delivered, while the store's own row said otherwise.
|
|
||||||
//
|
|
||||||
// Both checks moved to CatalogueProblems, which only ever runs in the control plane,
|
|
||||||
// after UseSeats has replaced the set with the store's.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isSystemSeatName(c.Name) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %q, which is a seat in the mesh's own namespace (mesh-*/node-*) that it "+
|
|
||||||
"does not define (novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, seatNames()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d, ours := defined[c.Name]
|
|
||||||
if !ours {
|
|
||||||
// **A claim on a seat this manifest does not declare is not the parser's to judge.**
|
|
||||||
// A module may hold a seat another module declared — that is why ADR 0126 has callers
|
|
||||||
// name the seat and not its provider, so an implementation can be replaced without
|
|
||||||
// touching a caller. Whether the seat exists is a fact about the whole catalogue, so
|
|
||||||
// the refusal is at registration, where every declaration is in view
|
|
||||||
// (`CatalogueProblems`: "which no module declares and the mesh does not define").
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.At() != d.At() {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims its own seat %s at scope %q, having declared it at %q",
|
|
||||||
m.Module, c.Name, c.At(), d.At()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at
|
|
||||||
// the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the
|
|
||||||
// store's row, so this is judged only where the store's set is loaded — at registration and in the
|
|
||||||
// handover command (novox/hq ADR 0131), never in the parser.
|
|
||||||
func CanHold(m Manifest, seat Seat) error {
|
|
||||||
var claimed *Claim
|
|
||||||
for i := range m.Claims {
|
|
||||||
if hs, ok := SeatNamed(m.Claims[i].Name); ok && hs.Name == seat.Name {
|
|
||||||
claimed = &m.Claims[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if claimed == nil {
|
|
||||||
return fmt.Errorf("%s does not claim %s", m.Module, seat.Name)
|
|
||||||
}
|
|
||||||
if claimed.At() != seat.Scope {
|
|
||||||
return fmt.Errorf("%s claims %s at scope %q, and %s is a %s seat",
|
|
||||||
m.Module, seat.Name, claimed.At(), seat.Name, seat.Scope)
|
|
||||||
}
|
|
||||||
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
|
|
||||||
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
|
||||||
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
|
||||||
}
|
|
||||||
// **A holder owns what the role owns** (novox/hq ADR 0287): every path the seat names, declared as
|
|
||||||
// a resource or as a file the mesh renders for it, so which module owns /etc/hosts on a machine is
|
|
||||||
// answered by the seat's definition and not by reading every manifest.
|
|
||||||
if missing := missingPaths(m, seat.Paths); len(missing) > 0 {
|
|
||||||
return fmt.Errorf("%s claims %s but does not declare %s, which that seat's holder owns (novox/hq "+
|
|
||||||
"ADR 0287) — a holder declares every path its seat names, as a resource or as a file the mesh "+
|
|
||||||
"renders for it", m.Module, seat.Name, strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
|
||||||
// does not answer what the role promises is every caller's timeout, found at registration and
|
|
||||||
// at handover instead, naming the verbs rather than the fact that something is missing.
|
|
||||||
if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); len(missing) > 0 {
|
|
||||||
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
|
|
||||||
"(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+
|
|
||||||
"serves or among its own tools",
|
|
||||||
m.Module, seat.Name, strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
// And nothing the seat does not promise: a verb named here that the protocol lacks is served
|
|
||||||
// to nobody, which is a typo the holder would otherwise discover as a caller's timeout.
|
|
||||||
if extra := unpromised(claimed.Serves, seat.Serves); len(extra) > 0 {
|
|
||||||
return fmt.Errorf("%s claims %s and says it serves %s, which that seat's protocol does not "+
|
|
||||||
"promise — a claim's serves names the seat's verbs and nothing else",
|
|
||||||
m.Module, seat.Name, strings.Join(extra, ", "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func providesAt(m Manifest, provision, scope string) bool {
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.Name == provision && o.At() == scope {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func seatNames() string {
|
|
||||||
names := make([]string, 0, len(seats))
|
|
||||||
for _, s := range seats {
|
|
||||||
names = append(names, s.Name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return strings.Join(names, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// HolderAmong is which of several providers of a provision holds the seat that delivers it.
|
|
||||||
//
|
|
||||||
// Found by the (node, module) pair, because a provider is identified by both (novox/hq to-be 23):
|
|
||||||
// two modules on one node could both provide a provision, and only the one holding the seat
|
|
||||||
// answers for it. Nothing when no seat delivers the provision, when nobody holds
|
|
||||||
// it, or when the holder is not among the providers offered.
|
|
||||||
//
|
|
||||||
// **The first holder in the providers' own order** (novox/hq ADR 0223). A replicated seat has
|
|
||||||
// several, and the answer must not depend on the order the mesh happened to resolve its machines
|
|
||||||
// in: the providers come sorted by machine, so every consumer is bound to the same one. A seat with
|
|
||||||
// one holder gets the same answer as before.
|
|
||||||
func HolderAmong(provision string, providers []Provider, held []Held) (Provider, bool) {
|
|
||||||
seat, delivered := SeatDelivering(provision)
|
|
||||||
if !delivered {
|
|
||||||
return Provider{}, false
|
|
||||||
}
|
|
||||||
for _, p := range providers {
|
|
||||||
for _, h := range held {
|
|
||||||
// Resolve the held claim to a seat rather than comparing names, so a record naming a
|
|
||||||
// seat's former name still matches it after a rename (novox/hq ADR 0122).
|
|
||||||
hs, ok := SeatNamed(h.Claim)
|
|
||||||
if !ok || hs.Name != seat.Name || h.Scope != seat.Scope {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.Node == h.Node && p.Module == h.Module {
|
|
||||||
return p, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Provider{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatsWithAProtocol are the mesh's own seats that say something about what may be said to them or by
|
|
||||||
// them, which is the set the bus derives streams, consumers and permissions from.
|
|
||||||
//
|
|
||||||
// Most of the set is not here, and that is the ordinary case: a seat saying only who does a job grants
|
|
||||||
// nothing on the bus and needs no queue.
|
|
||||||
func SeatsWithAProtocol() []Seat {
|
|
||||||
var out []Seat
|
|
||||||
for _, s := range seats {
|
|
||||||
if len(s.Accepts) > 0 || len(s.Emits) > 0 || len(s.Serves) > 0 {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
|
||||||
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
|
||||||
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
|
||||||
// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers
|
|
||||||
// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional
|
|
||||||
// (Verb.Optional) until every holder serves them (ADR 0246).
|
|
||||||
func serviceManagerVerbs() []Verb {
|
|
||||||
scoped := func(more map[string]string, required []string) map[string]any {
|
|
||||||
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
|
||||||
for k, v := range more {
|
|
||||||
props[k] = v
|
|
||||||
}
|
|
||||||
return schema(props, required)
|
|
||||||
}
|
|
||||||
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
|
||||||
return []Verb{
|
|
||||||
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
|
||||||
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}},
|
|
||||||
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}},
|
|
||||||
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}},
|
|
||||||
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}},
|
|
||||||
{Name: "restart", Description: "Restart one unit.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}},
|
|
||||||
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}},
|
|
||||||
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}},
|
|
||||||
// **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the
|
|
||||||
// minutes it happened in, and with no window on the verb a person reached for a shell. Every
|
|
||||||
// argument is the holder's to validate — passed to journalctl as one word of its own, never through
|
|
||||||
// a shell — and what the unit printed of a secret is redacted before it is answered.
|
|
||||||
{Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " +
|
|
||||||
"narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " +
|
|
||||||
"[redacted: <what it was>].",
|
|
||||||
Input: scoped(map[string]string{
|
|
||||||
"unit": unit["unit"],
|
|
||||||
"lines": "how many lines from the end of what matches (default 100, at most 2000)",
|
|
||||||
"since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)",
|
|
||||||
"until": "the window's end, in the same forms (optional; now when absent)",
|
|
||||||
"match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)",
|
|
||||||
"priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)",
|
|
||||||
}, []string{"unit"}),
|
|
||||||
Replaces: []string{"journalctl"}},
|
|
||||||
// What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers
|
|
||||||
// it, so a caller asks every machine the same way. **Optional while its holders catch up**: the
|
|
||||||
// systemd module running today serves it as its own systemd_failed, and a required verb would
|
|
||||||
// refuse it before the version serving `failed` could be delivered.
|
|
||||||
{Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " +
|
|
||||||
"account's; a manager that does not answer is reported with its error, never as nothing failed.",
|
|
||||||
Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil),
|
|
||||||
Replaces: []string{"systemctl --failed"}},
|
|
||||||
// **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose
|
|
||||||
// file is gone stays failed in its manager until the record is reset, and what keeps asking for it
|
|
||||||
// is a dependency or an enable link no verb could show. Required since every holder serves them
|
|
||||||
// (ADR 0246 step 3).
|
|
||||||
{Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " +
|
|
||||||
"its failed state and its start-limit count — and answer its state after. It removes no file and " +
|
|
||||||
"starts nothing.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}},
|
|
||||||
{Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " +
|
|
||||||
"manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " +
|
|
||||||
"every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " +
|
|
||||||
"each with where it points and whether it dangles — the account's own directory included in user scope.",
|
|
||||||
Input: scoped(unit, []string{"unit"}),
|
|
||||||
Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy",
|
|
||||||
"ls ~/.config/systemd/user/*.wants"}},
|
|
||||||
// **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable`
|
|
||||||
// leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since
|
|
||||||
// every holder serves it (ADR 0246 step 3).
|
|
||||||
{Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " +
|
|
||||||
"unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " +
|
|
||||||
"configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " +
|
|
||||||
"the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " +
|
|
||||||
"removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " +
|
|
||||||
"runtime placed, is left and said.",
|
|
||||||
Input: scoped(map[string]string{"unit": unit["unit"],
|
|
||||||
"why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}),
|
|
||||||
Replaces: []string{"rm ~/.config/systemd/user/*.wants/<unit>", "unlink /etc/systemd/system/*.wants/<unit>"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers
|
|
||||||
// about what the machine resolves through and over which links, whatever program manages its network.
|
|
||||||
func uplinkVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " +
|
|
||||||
"domains and options it lists, whether it is the file the mesh declares, and when it is not, who " +
|
|
||||||
"wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " +
|
|
||||||
"in its place, a known writer running.",
|
|
||||||
Input: schema(nil, nil), Optional: true},
|
|
||||||
{Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " +
|
|
||||||
"the default route leaves through it, and the resolvers and search domains the network manager " +
|
|
||||||
"knows for it — a VPN's tunnel included.",
|
|
||||||
Input: schema(nil, nil), Optional: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now,
|
|
||||||
// and restore beside the live data — never over it.
|
|
||||||
func backupVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " +
|
|
||||||
"its last good night, how many restore points are kept and the repository's size.",
|
|
||||||
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}},
|
|
||||||
{Name: "now", Description: "Take a backup now, of one module or of every module on this " +
|
|
||||||
"machine — before a migration, a retirement or anything else that could go wrong.",
|
|
||||||
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}},
|
|
||||||
{Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " +
|
|
||||||
"data, never over it: each directory as <path>.restored-<date>. Swapping it in is a " +
|
|
||||||
"person's act. Lists the restore points when none is named.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "the module",
|
|
||||||
"snapshot": "the restore point (from `backed-up`; the newest when omitted)",
|
|
||||||
"path": "one of the module's directories (all of them when omitted)",
|
|
||||||
}, []string{"module"}), Replaces: []string{"restic restore"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run
|
|
||||||
// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit
|
|
||||||
// so a hung command answers rather than times the caller out.
|
|
||||||
//
|
|
||||||
// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can
|
|
||||||
// become root without a person, so a machine may withhold it: the control-node does, through the
|
|
||||||
// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark
|
|
||||||
// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its
|
|
||||||
// silence on the bus is not judged — a holder withholding it serves nothing on the seat.
|
|
||||||
func loginShellVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " +
|
|
||||||
"non-interactive login shell in its home; answers with what it printed and how it exited.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"command": "the command line, as you would type it",
|
|
||||||
"timeout_seconds": "give up after this long, at most 25 (default 20)",
|
|
||||||
}, []string{"command"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAgentVerbs are what a holder of node-build-agent answers on its own machine (novox/hq ADR
|
|
||||||
// 0219). One build at a time per holder (ADR 0190), so "the build running here" is one or none.
|
|
||||||
func buildAgentVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "current", Description: "The build this machine is running — its id, repository, path, " +
|
|
||||||
"the step it is at, when it started and for how long — or none; and whether this machine is " +
|
|
||||||
"paused, taking no new build.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "kill", Description: "End the build with this id, running here: its commands and the " +
|
|
||||||
"containers it started are stopped, and its outcome is announced as failed, killed by hand — " +
|
|
||||||
"settled, so it is not handed to another machine.",
|
|
||||||
Input: schema(map[string]string{"id": "the build's id, as `queue` or `current` says it"}, []string{"id"})},
|
|
||||||
{Name: "pause", Description: "Take no new build on this machine until resumed; a build running " +
|
|
||||||
"here finishes. Kept across a restart of the holder.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "resume", Description: "Take builds again on this machine.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
|
||||||
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
|
||||||
const BrokerSeat = "mesh-broker"
|
|
||||||
|
|
||||||
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
|
|
||||||
// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a
|
|
||||||
// value for each, which the vault keeps as that holder's own and hands to it alone.
|
|
||||||
func withHolders(input map[string]any) map[string]any {
|
|
||||||
props, _ := input["properties"].(map[string]any)
|
|
||||||
props["holders"] = map[string]any{"type": "array",
|
|
||||||
"description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " +
|
|
||||||
"for each, kept as that holder's own and handed to it alone",
|
|
||||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
|
||||||
"node": map[string]any{"type": "string", "description": "the holder's machine"},
|
|
||||||
"module": map[string]any{"type": "string", "description": "the consuming module"}}}}
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
func vaultIntake(first map[string]string) map[string]string {
|
|
||||||
out := map[string]string{"call": "the second step: the call the first step answered",
|
|
||||||
"sealed": "the second step: the value sealed to the call's key — never the value"}
|
|
||||||
for k, v := range first {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Seats a module declares of its own (novox/hq ADR 0118).
|
|
||||||
//
|
|
||||||
// The set of seats a mesh has is **derived**: the mesh's own, in seats.go, plus those declared by
|
|
||||||
// every module it has registered. Still closed — a seat named nowhere is refused — but computed
|
|
||||||
// from the catalogue rather than written in the controller, which is what ADR 0110 actually
|
|
||||||
// needed and a hand-maintained table could not keep. Its own evidence: the enumeration done by
|
|
||||||
// hand while that record was written reported eleven claims where there were thirteen.
|
|
||||||
//
|
|
||||||
// **What can be checked from one manifest and what cannot.** A declaration's shape, its scope,
|
|
||||||
// and the reserved prefix are facts about the manifest in front of you. Whether a seat anybody
|
|
||||||
// names actually exists, whether two modules declared the same one, and whether a holder
|
|
||||||
// satisfies the protocol are facts about the *catalogue* — so they are checked at registration,
|
|
||||||
// by CatalogueProblems, which is the last moment the mesh can still say no.
|
|
||||||
|
|
||||||
// meshSeatPrefix is reserved to the mesh. The prefix *is* the reservation rule: no list of
|
|
||||||
// reserved names to maintain, no way for the mesh's own namespace to be colonised by a manifest,
|
|
||||||
// and nothing to keep in step when a mesh seat is added.
|
|
||||||
const meshSeatPrefix = "mesh-"
|
|
||||||
|
|
||||||
// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's,
|
|
||||||
// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204).
|
|
||||||
const retiredLoginShell = "login-shell"
|
|
||||||
|
|
||||||
// A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says,
|
|
||||||
// and what it answers. A caller declares that it uses the *seat*, never the module, so the
|
|
||||||
// implementation can be replaced under it.
|
|
||||||
type SeatDeclaration struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Scope string `json:"scope,omitempty"`
|
|
||||||
|
|
||||||
// Accepts are the verbs others may submit work on. Each becomes a work-queue subject, and
|
|
||||||
// the holder is the only consumer — so exactly one worker does the job, by construction
|
|
||||||
// rather than by how carefully somebody wrote a subscribe call.
|
|
||||||
Accepts []string `json:"accepts,omitempty"`
|
|
||||||
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
|
|
||||||
Emits []string `json:"emits,omitempty"`
|
|
||||||
// Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
|
|
||||||
// verb in full with its schema (novox/hq ADR 0132).
|
|
||||||
Serves []Verb `json:"serves,omitempty"`
|
|
||||||
|
|
||||||
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the
|
|
||||||
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
|
|
||||||
// owns its own, which is why a seat is also the answer for a module that needs retention
|
|
||||||
// its events cannot have.
|
|
||||||
RetainSeconds int `json:"retain-seconds,omitempty"`
|
|
||||||
|
|
||||||
// Kinded makes the seat a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): its holders are different
|
|
||||||
// modules, each claiming one kind, and each verb's subject carries the kind. Only the benches in
|
|
||||||
// KindedBenches may be kinded; making another is a decision, recorded.
|
|
||||||
Kinded bool `json:"kinded,omitempty"`
|
|
||||||
// ByCaller are accepts and emits whose subject's last token names the calling module (ADR 0259 §3): a
|
|
||||||
// user submits such an accept, and hears such an event, under its own name and no other.
|
|
||||||
ByCaller []string `json:"by-caller,omitempty"`
|
|
||||||
// Proofs are verbs carried as core request and reply, never on a stream: what travels on them (a code
|
|
||||||
// the operator typed) is never kept (ADR 0259 §3). On a kinded bench a holder asks with its own kind and
|
|
||||||
// the modules watching the seat answer.
|
|
||||||
Proofs []string `json:"proofs,omitempty"`
|
|
||||||
// Records are state buckets of the declaring module that each user reads under its own name — the
|
|
||||||
// keys `<user>.…` and no other (ADR 0259 §3).
|
|
||||||
Records []string `json:"records,omitempty"`
|
|
||||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287), under the rules the
|
|
||||||
// mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat
|
|
||||||
// names. A module's seat never steps back.
|
|
||||||
Paths []string `json:"paths,omitempty"`
|
|
||||||
// Needs is every node seat this seat's holder needs held on its own node (novox/hq ADR 0220, ADR
|
|
||||||
// 0378 §8): a role whose holder is only right while another role is filled beside it. A module
|
|
||||||
// claiming this seat depends on each, exactly as a module declaring a service depends on the
|
|
||||||
// service manager (ADR 0207) — derived from the claim, never written again in the claimant's own
|
|
||||||
// manifest. Only a node seat may carry needs, and a compiled seat carries none.
|
|
||||||
Needs []string `json:"needs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
|
||||||
// `intake` takes what the operator answers. Another is a decision, recorded, as ADR 0223 asks of a bench.
|
|
||||||
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
|
||||||
|
|
||||||
// NamedByCaller says whether one of the seat's verbs is named by its caller.
|
|
||||||
func (s SeatDeclaration) NamedByCaller(verb string) bool {
|
|
||||||
for _, v := range s.ByCaller {
|
|
||||||
if v == verb {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// At is this declaration's scope, with the default applied. Mesh by default, because a seat
|
|
||||||
// declared by a module is nearly always "there is one of these in the mesh" — a per-node worker
|
|
||||||
// is the deliberate case, and says so.
|
|
||||||
func (s SeatDeclaration) At() string {
|
|
||||||
if s.Scope == "" {
|
|
||||||
return ScopeMesh
|
|
||||||
}
|
|
||||||
return s.Scope
|
|
||||||
}
|
|
||||||
|
|
||||||
// verbs is everything the protocol names, for the checks that do not care which half.
|
|
||||||
func (s SeatDeclaration) verbs() []string {
|
|
||||||
out := append([]string{}, s.Accepts...)
|
|
||||||
out = append(out, s.Emits...)
|
|
||||||
return append(out, VerbNames(s.Serves)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// declaredSeatProblems is what one manifest can be judged on alone.
|
|
||||||
func declaredSeatProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
|
|
||||||
for _, s := range m.DefinesSeats {
|
|
||||||
switch {
|
|
||||||
case s.Name == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares a seat with no name", m.Module))
|
|
||||||
continue
|
|
||||||
case !name.MatchString(s.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares a seat named %q, which is not a usable name", m.Module, s.Name))
|
|
||||||
continue
|
|
||||||
case strings.HasPrefix(s.Name, meshSeatPrefix):
|
|
||||||
// The mesh's own code dereferences its seats by name — the resolver *is* the thing
|
|
||||||
// that finds the store — so the prefix is not a convention, it is a namespace.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares a seat named %q; %q is reserved to the mesh, which defines its own "+
|
|
||||||
"seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s.Name == retiredLoginShell {
|
|
||||||
// The name ADR 0176 gave the login shell when the zsh module declared it. The seat is
|
|
||||||
// the mesh's now, so a module declaring the old name would be a second login shell
|
|
||||||
// beside it, with a protocol of its own (novox/hq ADR 0204).
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+
|
|
||||||
"module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seen[s.Name] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares the seat %q twice", m.Module, s.Name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[s.Name] = true
|
|
||||||
|
|
||||||
if _, isMesh := SeatNamed(s.Name); isMesh {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %q, which is a seat the mesh already defines", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
switch s.At() {
|
|
||||||
case ScopeNode, ScopeSite, ScopeMesh:
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares seat %s at scope %q; a seat is held per node, per site or per mesh",
|
|
||||||
m.Module, s.Name, s.Scope))
|
|
||||||
}
|
|
||||||
// A seat with an empty protocol is allowed, and is the mesh saying what a machine is:
|
|
||||||
// which module is this node's packet filter, or its showcase. Design 26 calls it a seat
|
|
||||||
// that delivers nothing, and that is most of the node-scoped ones. ADR 0126's "a declared
|
|
||||||
// seat carries a protocol" governs what a holder must satisfy, not that every seat offers
|
|
||||||
// something — a marker seat's protocol is satisfied by holding it. Nothing can reach this
|
|
||||||
// state by accident: a mistyped field name is refused by the parser above, so an empty
|
|
||||||
// protocol was written as one.
|
|
||||||
for _, v := range s.verbs() {
|
|
||||||
if !name.MatchString(v) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
problems = append(problems, trafficProblems(m, s)...)
|
|
||||||
problems = append(problems, declaredSeatPathProblems(m, s)...)
|
|
||||||
problems = append(problems, declaredSeatNeedsProblems(m, s)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, u := range m.Uses {
|
|
||||||
if !name.MatchString(u) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s uses %q, which is not a usable seat name", m.Module, u))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone.
|
|
||||||
func trafficProblems(m Manifest, s SeatDeclaration) []string {
|
|
||||||
var problems []string
|
|
||||||
if s.Kinded && !KindedBenches[s.Name] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+
|
|
||||||
"decision, recorded (novox/hq ADR 0234)", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
if s.Kinded && len(s.ByCaller) > 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind",
|
|
||||||
m.Module, s.Name))
|
|
||||||
}
|
|
||||||
for _, v := range s.ByCaller {
|
|
||||||
inAccepts, inEmits := false, false
|
|
||||||
for _, a := range s.Accepts {
|
|
||||||
inAccepts = inAccepts || a == v
|
|
||||||
}
|
|
||||||
for _, e := range s.Emits {
|
|
||||||
inEmits = inEmits || e == v
|
|
||||||
}
|
|
||||||
if !inAccepts && !inEmits {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, v := range s.Proofs {
|
|
||||||
if !name.MatchString(v) || strings.Contains(v, ".") {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb",
|
|
||||||
m.Module, s.Name, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(s.Proofs) > 0 && !s.Kinded {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind",
|
|
||||||
m.Module, s.Name))
|
|
||||||
}
|
|
||||||
for _, r := range s.Records {
|
|
||||||
kept := false
|
|
||||||
for _, st := range m.State {
|
|
||||||
kept = kept || st.Name == r
|
|
||||||
}
|
|
||||||
if !kept {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR
|
|
||||||
// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may
|
|
||||||
// carry needs; and a seat needing itself names nothing that was not already true of holding it.
|
|
||||||
// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems'
|
|
||||||
// to say, once every manifest is known.
|
|
||||||
func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string {
|
|
||||||
var problems []string
|
|
||||||
if len(s.Needs) == 0 {
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if s.At() != ScopeNode {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s needing %s, and only a node seat needs another held beside it "+
|
|
||||||
"(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", ")))
|
|
||||||
}
|
|
||||||
for _, need := range s.Needs {
|
|
||||||
if need == s.Name {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Shelf is every manifest the mesh has registered, by module name.
|
|
||||||
type Shelf map[string]Manifest
|
|
||||||
|
|
||||||
// CatalogueProblems are the rules no single manifest can be judged against.
|
|
||||||
//
|
|
||||||
// Run at registration, which is the last moment the mesh can still refuse: after it, a caller is
|
|
||||||
// bound to a seat and a refusal is an outage rather than a conversation.
|
|
||||||
// UndeclaredSeat ends the problem of a `uses` or a claim naming a seat no manifest given declares: over the whole
|
|
||||||
// catalogue (registration, the catalogue's own check) a refusal, and over some manifests alone a seat whose
|
|
||||||
// declaring module was not given (novox/hq issue 364).
|
|
||||||
const UndeclaredSeat = "which no module declares and the mesh does not define"
|
|
||||||
|
|
||||||
// IsUndeclaredSeat says whether a problem CatalogueProblems gave is a seat no manifest given declares.
|
|
||||||
func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) }
|
|
||||||
|
|
||||||
func CatalogueProblems(shelf Shelf) []string {
|
|
||||||
var problems []string
|
|
||||||
// A stream name has one owner (novox/hq ADR 0363).
|
|
||||||
problems = append(problems, StreamNameProblems(shelf)...)
|
|
||||||
// A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2).
|
|
||||||
problems = append(problems, RestrictedHearingProblems(shelf)...)
|
|
||||||
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
|
|
||||||
problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...)
|
|
||||||
|
|
||||||
// Who declares what, and who declared it first.
|
|
||||||
declaredBy := map[string]string{}
|
|
||||||
declared := map[string]SeatDeclaration{}
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
for _, s := range shelf[module].DefinesSeats {
|
|
||||||
if s.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if first, taken := declaredBy[s.Name]; taken {
|
|
||||||
// The second loses. A seat name meaning two different protocols is the failure
|
|
||||||
// nobody could diagnose afterwards — a caller would bind to whichever happened
|
|
||||||
// to register first, and the symptom would appear in the other module.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares the seat %q, which %s already declares; a seat name means one "+
|
|
||||||
"protocol", module, s.Name, first))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
declaredBy[s.Name] = module
|
|
||||||
declared[s.Name] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exists := func(seat string) bool {
|
|
||||||
if _, isMesh := SeatNamed(seat); isMesh {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, ok := declaredBy[seat]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// atNode says whether a seat, compiled or declared, is a node seat — judged here because only
|
|
||||||
// the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8).
|
|
||||||
atNode := func(seat string) bool {
|
|
||||||
if s, isMesh := SeatNamed(seat); isMesh {
|
|
||||||
return s.Scope == ScopeNode
|
|
||||||
}
|
|
||||||
if s, ok := declared[seat]; ok {
|
|
||||||
return s.At() == ScopeNode
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8):
|
|
||||||
// whether one exists at all, compiled or declared by any module, only the whole catalogue can
|
|
||||||
// say — a single manifest's own problems (declaredSeatProblems) judge its needs no further.
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
for _, s := range shelf[module].DefinesSeats {
|
|
||||||
for _, need := range s.Needs {
|
|
||||||
if !exists(need) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need))
|
|
||||||
} else if !atNode(need) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)",
|
|
||||||
module, s.Name, need))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
|
|
||||||
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
|
|
||||||
problems = append(problems, pathProblems...)
|
|
||||||
|
|
||||||
// Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2).
|
|
||||||
kindsTaken := map[string]string{}
|
|
||||||
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
m := shelf[module]
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if c.Kind != "" {
|
|
||||||
if _, isModuleSeat := declared[c.Name]; !isModuleSeat {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the
|
|
||||||
// same refusal, at the same moment, from a set nobody maintains by hand.
|
|
||||||
for _, u := range m.Uses {
|
|
||||||
if !exists(u) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s uses the seat %q, "+UndeclaredSeat,
|
|
||||||
module, u))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if !exists(c.Name) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims the seat %q, "+UndeclaredSeat,
|
|
||||||
module, c.Name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s, isModuleSeat := declared[c.Name]
|
|
||||||
if !isModuleSeat {
|
|
||||||
// **A mesh seat is judged here and nowhere else** (novox/hq ADR 0122): the set is
|
|
||||||
// the store's, and this is the only place that runs with the store's set loaded.
|
|
||||||
// The parser cannot do it — it also runs on the build machine, against whatever
|
|
||||||
// set that binary was compiled with.
|
|
||||||
seat, _ := SeatNamed(c.Name)
|
|
||||||
if err := CanHold(m, seat); err != nil {
|
|
||||||
problems = append(problems, err.Error())
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, kindProblems(module, c, s, kindsTaken)...)
|
|
||||||
if c.At() != s.At() {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %s at scope %q, and %s declares it at %s",
|
|
||||||
module, c.Name, c.At(), declaredBy[c.Name], s.At()))
|
|
||||||
}
|
|
||||||
// A holder that does not answer what the seat promises is a caller's timeout, found
|
|
||||||
// at assignment instead.
|
|
||||||
if missing := unserved(m, c, s); len(missing) > 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
|
||||||
module, c.Name, strings.Join(missing, ", ")))
|
|
||||||
}
|
|
||||||
// And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287).
|
|
||||||
if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)",
|
|
||||||
module, c.Name, strings.Join(missing, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only a claimant declares a seat's path (novox/hq ADR 0287).
|
|
||||||
problems = append(problems, unclaimedSeatPaths(m, named)...)
|
|
||||||
}
|
|
||||||
// **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or
|
|
||||||
// speaking for a kind that proves its sender, is never carried by a machine's runtime.
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
m := shelf[module]
|
|
||||||
if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+
|
|
||||||
"operator's account, which every agent runs as (novox/hq ADR 0259)", module, why))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
|
||||||
// owner is on the shelf, as a consumer may be installed before its emitter.
|
|
||||||
var manifests []Manifest
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
manifests = append(manifests, shelf[module])
|
|
||||||
}
|
|
||||||
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
|
||||||
sort.Strings(problems)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word
|
|
||||||
// outside it is refused. `max-length:<N>` takes a number.
|
|
||||||
var ChannelCapabilities = map[string]bool{
|
|
||||||
"deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true,
|
|
||||||
"reaches-when-mesh-down": true, "private": true,
|
|
||||||
"choice": true, "reply": true, "threads": true, "operator-first": true,
|
|
||||||
"verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`)
|
|
||||||
|
|
||||||
// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a
|
|
||||||
// seat that is not kinded.
|
|
||||||
func capabilityProblems(module string, c Claim, kinded bool) []string {
|
|
||||||
if len(c.Capabilities) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !kinded {
|
|
||||||
return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them",
|
|
||||||
module, c.Name)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, w := range c.Capabilities {
|
|
||||||
if !ChannelCapabilities[w] && !maxLength.MatchString(w) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind,
|
|
||||||
// a usable name; any other seat takes none.
|
|
||||||
func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string {
|
|
||||||
if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 {
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case !s.Kinded && c.Kind != "":
|
|
||||||
return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind",
|
|
||||||
module, c.Name, c.Kind)}
|
|
||||||
case !s.Kinded:
|
|
||||||
return nil
|
|
||||||
case c.Kind == "":
|
|
||||||
return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)}
|
|
||||||
case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."):
|
|
||||||
return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)}
|
|
||||||
}
|
|
||||||
key := c.Name + "/" + c.Kind
|
|
||||||
if first, ok := taken[key]; ok && first != module {
|
|
||||||
return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder",
|
|
||||||
module, c.Name, c.Kind, first)}
|
|
||||||
}
|
|
||||||
taken[key] = module
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
|
||||||
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
|
||||||
// is code the module either has or has not written — under the claim's serves, or among its own.
|
|
||||||
func unserved(m Manifest, c Claim, s SeatDeclaration) []string {
|
|
||||||
has := map[string]bool{}
|
|
||||||
for _, t := range c.ServesFor(m) {
|
|
||||||
has[t] = true
|
|
||||||
}
|
|
||||||
var missing []string
|
|
||||||
for _, t := range s.Serves {
|
|
||||||
if !has[t.Name] {
|
|
||||||
missing = append(missing, t.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing
|
|
||||||
}
|
|
||||||
|
|
||||||
// shelfOrder is the catalogue in a stable order, so two runs report the same problems in the same
|
|
||||||
// sequence — a refusal that reorders itself is a refusal nobody can diff.
|
|
||||||
func shelfOrder(shelf Shelf) []string {
|
|
||||||
out := make([]string, 0, len(shelf))
|
|
||||||
for k := range shelf {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`)
|
|
||||||
|
|
||||||
// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the
|
|
||||||
// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own,
|
|
||||||
// and that is neither root nor the operator's.
|
|
||||||
func RunsAsProblems(m Manifest) []string {
|
|
||||||
if m.RunsAs == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
|
|
||||||
switch {
|
|
||||||
case !accountName.MatchString(m.RunsAs):
|
|
||||||
say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs)
|
|
||||||
return problems
|
|
||||||
case m.RunsAs == "root":
|
|
||||||
say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module)
|
|
||||||
}
|
|
||||||
made := false
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs {
|
|
||||||
made = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !made {
|
|
||||||
say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs)
|
|
||||||
}
|
|
||||||
if _, has := m.OwnSecrets["broker"]; !has {
|
|
||||||
say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+
|
|
||||||
"account of its own", m.Module)
|
|
||||||
}
|
|
||||||
if m.SecretsOwner != m.RunsAs {
|
|
||||||
say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner)
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat
|
|
||||||
// whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves
|
|
||||||
// its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which
|
|
||||||
// runs as the operator's account.
|
|
||||||
func TrustedHolding(m Manifest, declared map[string]SeatDeclaration) string {
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
s, ok := declared[c.Name]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, e := range s.Emits {
|
|
||||||
if s.NamedByCaller(e) {
|
|
||||||
return fmt.Sprintf("it holds %s, whose %s it says to one caller each", c.Name, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.Kinded {
|
|
||||||
for _, capability := range c.Capabilities {
|
|
||||||
switch capability {
|
|
||||||
case "verified-sender":
|
|
||||||
return fmt.Sprintf("it holds %s of kind %s, which proves its sender", c.Name, c.Kind)
|
|
||||||
case "private":
|
|
||||||
// A private kind is shown a link's code, which makes an account the operator's.
|
|
||||||
return fmt.Sprintf("it holds %s of kind %s, which is private: a link's code is shown there", c.Name, c.Kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A credential and a configuration file meeting.
|
|
||||||
//
|
|
||||||
// **The gap this closes.** A granted credential arrives as a file whose entire content is the
|
|
||||||
// password. That is what a program reading a password file wants — and most programs do not read
|
|
||||||
// one. They read `KEY=value`, or a JSON document with the password at some path inside it, or a
|
|
||||||
// YAML file in a home directory. Before this, a module in that position could be handed the bare
|
|
||||||
// value or nothing, and both are useless.
|
|
||||||
//
|
|
||||||
// The mesh cannot compose the document, because it discarded the value (novox/hq ADR 0024). So
|
|
||||||
// the module supplies the document with a hole in it, the mesh delivers the value sealed beside
|
|
||||||
// it, and the host — the only thing that ever sees both — puts one into the other on the machine.
|
|
||||||
//
|
|
||||||
// The host has always been able to do this. Nothing filled the values in, so the hole could be
|
|
||||||
// written and never closed, and the host refused the file. That refusal was correct and the
|
|
||||||
// feature was unreachable.
|
|
||||||
|
|
||||||
// placeholder is what a module's file content says where a sealed value belongs: ${secret:name}.
|
|
||||||
//
|
|
||||||
// The same expression the host matches, written out again rather than shared: they are separate
|
|
||||||
// repositories and this is a wire format, like the shape of the declaration itself.
|
|
||||||
//
|
|
||||||
// **Nothing here can check that they agree, and the claim that something did was wrong.** A unit
|
|
||||||
// test in this repository can only assert what this repository already believes. What proves it is
|
|
||||||
// the lab, where a real host receives a real declaration and the file arrives filled — and where
|
|
||||||
// the two expressions disagreeing shows up as a placeholder written through to a machine.
|
|
||||||
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
|
|
||||||
|
|
||||||
// secretsUsed are the names a file's content asks for, in the order they first appear.
|
|
||||||
func secretsUsed(content string) []string {
|
|
||||||
var used []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range placeholder.FindAllStringSubmatch(content, -1) {
|
|
||||||
if !seen[m[1]] {
|
|
||||||
seen[m[1]] = true
|
|
||||||
used = append(used, m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return used
|
|
||||||
}
|
|
||||||
|
|
||||||
// sealedFor is every credential a module may name from inside one of its own files.
|
|
||||||
//
|
|
||||||
// **Exactly what it already declared, and nothing else.** A module reaches its own secrets and the
|
|
||||||
// credentials it was granted for what it requires — both written down in its own manifest. It
|
|
||||||
// cannot name another module's, which is not an oversight: two modules on one machine are as
|
|
||||||
// separate as two on different machines, and letting one read the other's credential by guessing a
|
|
||||||
// name would end that, to save writing a file.
|
|
||||||
func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, error) {
|
|
||||||
sealed := map[string]string{}
|
|
||||||
for name := range m.OwnSecrets.Plain() {
|
|
||||||
if value := with.Needed[m.Module][name]; value != "" {
|
|
||||||
sealed[name] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, to := range m.SecretRequirements() {
|
|
||||||
for _, file := range m.SecretFiles(to) {
|
|
||||||
key := SecretLocal(to, file.Local)
|
|
||||||
if _, taken := sealed[key]; taken {
|
|
||||||
// A module whose own secret and whose requirement share a name. Refused rather than
|
|
||||||
// settled by precedence: whichever won, the manifest would read as though the other
|
|
||||||
// had, and the file would hold the credential for the wrong thing while every check
|
|
||||||
// passed.
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s has a secret of its own called %q and also requires %q, so a file saying "+
|
|
||||||
"${secret:%s} could mean either — rename one of them", m.Module, key, key, key)
|
|
||||||
}
|
|
||||||
for i := range needs {
|
|
||||||
// `For == m.Module`, not name alone: on a node with two modules requiring the same
|
|
||||||
// provision, both appear in `needs`, and matching by name would fill ${secret:X} with
|
|
||||||
// whichever came last — the other module's credential (novox/hq 04-ISSUES/022). And
|
|
||||||
// the local name, where the module keeps several (ADR 0094).
|
|
||||||
if needs[i].Name == to && needs[i].For == m.Module && needs[i].Local == file.Local && needs[i].Sealed != "" {
|
|
||||||
sealed[key] = needs[i].Sealed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sealed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretsInEnvironment is the key a container carries to say, out loud, that a secret reaches
|
|
||||||
// its process through the environment — and why.
|
|
||||||
//
|
|
||||||
// **A secret reaches a process as a file** (novox/hq ADR 0086). The mesh seals a value to the
|
|
||||||
// machine and the host writes it at 0600; a container that then reads it from an env-file hands
|
|
||||||
// it to the runtime, which prints it in `docker inspect` and keeps it in the process's /proc entry
|
|
||||||
// for anything on the machine that can talk to the runtime (novox/hq 04-ISSUES/041). Some software
|
|
||||||
// reads its configuration from the environment and nothing else, and for that this key exists: a
|
|
||||||
// reason, on the container, so a reader can tell from the manifest which secrets are exposed that
|
|
||||||
// way and which are not. Without it, a container whose env-file carries a secret is refused.
|
|
||||||
//
|
|
||||||
// Catalogue-level: the host never sees this key.
|
|
||||||
const SecretsInEnvironment = "secrets-in-environment"
|
|
||||||
|
|
||||||
// refuseSecretsInEnvironment is the check. `secretFiles` is every file of this module whose
|
|
||||||
// content names a secret.
|
|
||||||
func refuseSecretsInEnvironment(resource map[string]any, secretFiles map[string]bool, module string) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "container" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name := fmt.Sprint(resource["name"])
|
|
||||||
if env, ok := resource["env"].(map[string]any); ok {
|
|
||||||
for key, value := range env {
|
|
||||||
if strings.Contains(fmt.Sprint(value), "${secret:") {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s's container %s puts ${secret:…} in its env (%s). A secret is never filled into an "+
|
|
||||||
"environment variable: put it in a file the module declares and mount that, or name the "+
|
|
||||||
"file in env-file and say %q why", module, name, key, SecretsInEnvironment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reason, _ := resource[SecretsInEnvironment].(string)
|
|
||||||
if strings.TrimSpace(reason) != "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if files, ok := resource["env-file"].([]any); ok {
|
|
||||||
for _, f := range files {
|
|
||||||
if secretFiles[fmt.Sprint(f)] {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s's container %s reads %s as an env-file, and that file carries a secret, so the secret "+
|
|
||||||
"reaches the process environment — readable in `docker inspect` and /proc (novox/hq "+
|
|
||||||
"04-ISSUES/041). Mount the secret's file and point the program at it, or, if the program "+
|
|
||||||
"reads only its environment, say so on the container: %q: \"<why>\"",
|
|
||||||
module, name, f, SecretsInEnvironment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretFilesOf is the paths of a module's file resources whose content names a secret.
|
|
||||||
func secretFilesOf(resources []map[string]any) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, r := range resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if content, ok := r["content"].(string); ok && len(secretsUsed(content)) > 0 {
|
|
||||||
out[fmt.Sprint(r["path"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// intoFile gives a file the sealed values its content asks for.
|
|
||||||
//
|
|
||||||
// A name the module never declared is refused here rather than on the machine. The host would
|
|
||||||
// refuse it too — but it would do so having already been handed a declaration, which reads as the
|
|
||||||
// mesh sending something broken, and the name it could not find is a manifest's typo.
|
|
||||||
//
|
|
||||||
// **An own secret issued outside the mesh that nobody gave is filled with nothing** (novox/hq issue 401): the mesh
|
|
||||||
// never makes one (ADR 0228), and a file is where the module reads it, so the place says empty — the module sees it
|
|
||||||
// was not given, where a value the mesh made looked given and wrong. notGiven names those secrets.
|
|
||||||
func intoFile(resource map[string]any, sealed map[string]string, notGiven map[string]bool, module string) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
used := secretsUsed(content)
|
|
||||||
if len(used) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
into := map[string]any{}
|
|
||||||
for _, name := range used {
|
|
||||||
value := sealed[name]
|
|
||||||
if value == "" && notGiven[name] {
|
|
||||||
content = strings.ReplaceAll(content, "${secret:"+name+"}", "")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if value == "" {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+
|
|
||||||
"name and requires nothing called that either. A file may name %s",
|
|
||||||
module, name, module, namesOr(sealed))
|
|
||||||
}
|
|
||||||
into[name] = value
|
|
||||||
}
|
|
||||||
resource["content"] = content
|
|
||||||
if len(into) > 0 {
|
|
||||||
resource["secrets"] = into
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// outsideNotGiven is each own secret of a module issued outside the mesh that this composition holds no value for
|
|
||||||
// (novox/hq issue 401): what intoFile fills with nothing, and what nothing restarts on.
|
|
||||||
func outsideNotGiven(m Manifest, given map[string]string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for name, own := range m.OwnSecrets.Plain() {
|
|
||||||
if own.IssuedBy == IssuedOutside && given[name] == "" {
|
|
||||||
out[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesOr says what a module could have written, because the answer to "that name is wrong" is
|
|
||||||
// almost always one of two or three right ones.
|
|
||||||
func namesOr(sealed map[string]string) string {
|
|
||||||
if len(sealed) == 0 {
|
|
||||||
return "nothing — it has no secrets of its own and requires nothing that grants one"
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for name := range sealed {
|
|
||||||
names = append(names, fmt.Sprintf("%q", name))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return join(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
func join(names []string) string {
|
|
||||||
switch len(names) {
|
|
||||||
case 1:
|
|
||||||
return names[0]
|
|
||||||
case 2:
|
|
||||||
return names[0] + " or " + names[1]
|
|
||||||
}
|
|
||||||
out := ""
|
|
||||||
for i, n := range names[:len(names)-1] {
|
|
||||||
if i > 0 {
|
|
||||||
out += ", "
|
|
||||||
}
|
|
||||||
out += n
|
|
||||||
}
|
|
||||||
return out + " or " + names[len(names)-1]
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624).
|
|
||||||
//
|
|
||||||
// **The name is already the mesh's.** A module contributes a label to a requirement — a route — and
|
|
||||||
// the mesh joins it with the providing machine's domains; the module reads the result in its own files
|
|
||||||
// as `${bound:<requirement>:name}` (novox/hq 04-ISSUES/122). Its consumers could not be told it: a
|
|
||||||
// served value named only the consumer (ADR 0202) and settings. The forge's package registry writes
|
|
||||||
// every tarball at its public name, and a consumer that must trust that name had no source for it but a
|
|
||||||
// second copy kept by hand. So a served value may say `${bound:<requirement>:<name>}` for a requirement
|
|
||||||
// the provider contributes to, and the mesh fills it from the same composition the provider's own files
|
|
||||||
// and its proxy receive.
|
|
||||||
//
|
|
||||||
// **A name the providing machine does not compose leaves the key out** (ADR 0346 §3): a machine with
|
|
||||||
// no public domain has no public name to give, and an empty value or a refusal would be worse than a
|
|
||||||
// consumer told nothing — a refused provider machine takes every provision it offers down with it. The
|
|
||||||
// plan says which key was left out, and why, on the provider's machine and under each consumer's need.
|
|
||||||
|
|
||||||
// ownNameFacts are what a served value may name of the provider's own composition: the names, as its
|
|
||||||
// own files read them (withOwnNames).
|
|
||||||
var ownNameFacts = []string{"name", "internal-name"}
|
|
||||||
|
|
||||||
// requirementsContributedTo is every requirement m contributes to, sorted.
|
|
||||||
func requirementsContributedTo(m Manifest) []string {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for to := range m.Contributes {
|
|
||||||
seen[to] = true
|
|
||||||
}
|
|
||||||
for to := range m.ContributesMany {
|
|
||||||
seen[to] = true
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(seen))
|
|
||||||
for to := range seen {
|
|
||||||
out = append(out, to)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownNameKeys is every name a served value of m may say for one requirement it contributes to:
|
|
||||||
// `name` and `internal-name`, and `name-<local>` and `internal-name-<local>` for each local name it
|
|
||||||
// contributes under.
|
|
||||||
func ownNameKeys(m Manifest, to string) []string {
|
|
||||||
keys := append([]string{}, ownNameFacts...)
|
|
||||||
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
|
||||||
for _, fact := range ownNameFacts {
|
|
||||||
keys = append(keys, fact+"-"+local)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServedOwnNames fills the provider's own composed names into what it serves one provision's
|
|
||||||
// consumers, from this resolution's composition of the provider's machine (ADR 0346).
|
|
||||||
//
|
|
||||||
// A value naming a name this machine does not compose is left out, with its key, and said. The map is
|
|
||||||
// copied only when something changes, as ServedTo does: the caller's may be the manifest's.
|
|
||||||
func (r Resolution) ServedOwnNames(m Manifest, provision string, serves map[string]any, layers []Layer) (
|
|
||||||
map[string]any, []string, error) {
|
|
||||||
var asking []string
|
|
||||||
for _, key := range sortedAnyKeys(serves) {
|
|
||||||
if text, ok := serves[key].(string); ok && bound.MatchString(text) {
|
|
||||||
asking = append(asking, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(asking) == 0 {
|
|
||||||
return serves, nil, nil
|
|
||||||
}
|
|
||||||
own := map[string]map[string]string{}
|
|
||||||
for _, to := range requirementsContributedTo(m) {
|
|
||||||
names, err := r.ownNames(m, to, layers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
values := map[string]string{}
|
|
||||||
withOwnNames(values, names)
|
|
||||||
own[to] = values
|
|
||||||
}
|
|
||||||
out := make(map[string]any, len(serves))
|
|
||||||
for k, v := range serves {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
var said []string
|
|
||||||
for _, key := range asking {
|
|
||||||
text := serves[key].(string)
|
|
||||||
var missing string
|
|
||||||
filled := bound.ReplaceAllStringFunc(text, func(token string) string {
|
|
||||||
parts := bound.FindStringSubmatch(token)
|
|
||||||
if value := own[parts[1]][parts[2]]; value != "" && parts[3] == "" {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if missing == "" {
|
|
||||||
missing = token
|
|
||||||
}
|
|
||||||
return token
|
|
||||||
})
|
|
||||||
if missing != "" {
|
|
||||||
delete(out, key)
|
|
||||||
said = append(said, fmt.Sprintf(
|
|
||||||
"%s serves %s without %q: it names %s, and %s composes no such name for %s (it has no "+
|
|
||||||
"public domain, or the endpoint is not reached publicly) (novox/hq ADR 0346)",
|
|
||||||
m.Module, provision, key, missing, orHere(r.Node), m.Module))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[key] = filled
|
|
||||||
}
|
|
||||||
return out, said, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func orHere(node string) string {
|
|
||||||
if node == "" {
|
|
||||||
return "this machine"
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServedLeftOut is what the providers in this node's own set leave out of what they serve, because
|
|
||||||
// this machine composes no name a served value asks for (ADR 0346 §3): said on the provider's machine,
|
|
||||||
// where the remedy is.
|
|
||||||
func (r Resolution) ServedLeftOut(settings SettingsBy) []string {
|
|
||||||
var said []string
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
for _, provision := range sortedServes(m.Serves) {
|
|
||||||
_, lines, err := r.ServedOwnNames(m, provision, m.Serves[provision], WithDefaults(m, settings[m.Module]))
|
|
||||||
if err != nil {
|
|
||||||
said = append(said, fmt.Sprintf("%s serves %s, and its own names could not be composed: %v",
|
|
||||||
m.Module, provision, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
said = append(said, lines...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return said
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not
|
|
||||||
// contribute to, a fact other than its own names, or a local name it does not contribute under; and
|
|
||||||
// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4).
|
|
||||||
func checkServedOwnNames(m Manifest, provision, key, text string) []string {
|
|
||||||
var problems []string
|
|
||||||
contributes := requirementsContributedTo(m)
|
|
||||||
for _, parts := range bound.FindAllStringSubmatch(text, -1) {
|
|
||||||
to, fact, fallback := parts[1], parts[2], parts[3]
|
|
||||||
says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0])
|
|
||||||
switch {
|
|
||||||
case fallback != "":
|
|
||||||
problems = append(problems, says+": a served name takes no default; one the machine does not "+
|
|
||||||
"compose leaves the key out (novox/hq ADR 0346)")
|
|
||||||
case !oneOf(contributes, to):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s, and %s contributes to %s: a served value names only the provider's own names "+
|
|
||||||
"(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes))))
|
|
||||||
case !oneOf(ownNameKeys(m, to), fact):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)",
|
|
||||||
says, m.Module, to, strings.Join(ownNameKeys(m, to), ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoted(values []string) []string {
|
|
||||||
out := make([]string, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
out[i] = fmt.Sprintf("%q", v)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// nestedOwnNames refuses a `${bound:…}` inside a served list or object: only a string value of its own is
|
|
||||||
// filled (ServedOwnNames), so a nested one would reach the consumer as the placeholder (ADR 0346 §5).
|
|
||||||
func nestedOwnNames(m Manifest, provision, key string, value any) []string {
|
|
||||||
var found []string
|
|
||||||
var walk func(v any)
|
|
||||||
walk = func(v any) {
|
|
||||||
switch x := v.(type) {
|
|
||||||
case string:
|
|
||||||
found = append(found, bound.FindAllString(x, -1)...)
|
|
||||||
case []any:
|
|
||||||
for _, e := range x {
|
|
||||||
walk(e)
|
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
for _, k := range sortedAnyKeys(x) {
|
|
||||||
walk(x[k])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(value)
|
|
||||||
if len(found) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []string{fmt.Sprintf(
|
|
||||||
"%s serves %s, and the value it serves as %q holds %s inside a list or an object: a provider's own "+
|
|
||||||
"name is filled only in a string value of its own (novox/hq ADR 0346)",
|
|
||||||
m.Module, provision, key, strings.Join(found, ", "))}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A preference has a default; the operator's own value has none (novox/hq ADR 0262, extending ADR
|
|
||||||
// 0112 and taking the default half of ADR 0164).
|
|
||||||
//
|
|
||||||
// `${setting:<key>}` was refused whenever no layer set the key, and `settings set` refused a key no
|
|
||||||
// file asked for yet. Together they meant a running module could never gain a setting: the file that
|
|
||||||
// asks for it fails to compose until somebody sets it, and nobody can set it until the file asks.
|
|
||||||
// A definition may now give a key a default, with why, when the value is a **preference** — a font
|
|
||||||
// size, a width, a number of workers: something true of the software that a machine may tune. A
|
|
||||||
// value that is inherently the operator's — a domain, a public name, an identity, a secret — gets no
|
|
||||||
// default and stays refused by name until a layer sets it, which is what ADR 0112 and ADR 0155 were
|
|
||||||
// written for.
|
|
||||||
//
|
|
||||||
// The default is the lowest layer. The mesh's layer, then the node's, override it. It fills only
|
|
||||||
// `${setting:<key>}`: a mergeable file's content already is its defaults, and laying a default over
|
|
||||||
// it would reach every mergeable file of the module (novox/hq issue 168).
|
|
||||||
|
|
||||||
// SettingDeclaration is one key's default, as the definition gives it.
|
|
||||||
type SettingDeclaration struct {
|
|
||||||
// Kind says what sort of value this is. `preference` is the only kind with a default; it is
|
|
||||||
// stated rather than assumed so a reviewer sees the claim being made.
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
// Default is the value when no layer sets the key: a string, a number or a boolean.
|
|
||||||
Default any `json:"default"`
|
|
||||||
// Why this default: one sentence for whoever wonders whether to change it.
|
|
||||||
Why string `json:"why"`
|
|
||||||
|
|
||||||
// The config form's declaration (novox/hq ADR 0348 §4, config.go): none of these is said under
|
|
||||||
// `settings`, and neither Kind nor Why under `config`.
|
|
||||||
|
|
||||||
// Type is number, string or boolean; object or list only when Trusted.
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
// Description says what the value is, including its unit.
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
// Enum, Minimum, Maximum and Pattern are its validation: the values it may take, the bounds of a
|
|
||||||
// number, and the whole of a string matching the pattern.
|
|
||||||
Enum []any `json:"enum,omitempty"`
|
|
||||||
Minimum *float64 `json:"minimum,omitempty"`
|
|
||||||
Maximum *float64 `json:"maximum,omitempty"`
|
|
||||||
Pattern string `json:"pattern,omitempty"`
|
|
||||||
// Trusted is a value that decides what runs or who can reach what (ADR 0348 §8).
|
|
||||||
Trusted bool `json:"trusted,omitempty"`
|
|
||||||
// Predefined is the predefined key the value falls back to, of the same type (ADR 0348 §2, §7).
|
|
||||||
Predefined string `json:"predefined,omitempty"`
|
|
||||||
// Required is the operator's own value: it has no default (ADR 0348 §4).
|
|
||||||
Required bool `json:"required,omitempty"`
|
|
||||||
// Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5).
|
|
||||||
Runtime bool `json:"runtime,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KindPreference is the kind of a setting that may have a default.
|
|
||||||
const KindPreference = "preference"
|
|
||||||
|
|
||||||
// DefaultLayer is what the layer of a module's own defaults is called where a value's source is said.
|
|
||||||
// Only said: the layer is recognised by Layer.Default, never by this name, which a node may also have.
|
|
||||||
const DefaultLayer = "default"
|
|
||||||
|
|
||||||
// meshWords are the settings keys the mesh reads itself; a module declares none of them.
|
|
||||||
var meshWords = map[string]bool{
|
|
||||||
PortsSetting: true, ExposeSetting: true, ReachSetting: true, EndpointsSetting: true,
|
|
||||||
PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// operatorsOwn are the words that, as what a key's name is about, say its value is the operator's and
|
|
||||||
// never a preference: a default for one would be the literal ADR 0112 removed from definitions.
|
|
||||||
var operatorsOwn = map[string]bool{
|
|
||||||
"domain": true, "host": true, "hostname": true, "servername": true, "fqdn": true, "zone": true,
|
|
||||||
"realm": true, "tenant": true, "site": true, "timezone": true,
|
|
||||||
"issuer": true, "url": true, "uri": true, "webhook": true, "origin": true, "dsn": true,
|
|
||||||
"ip": true, "ipv4": true, "ipv6": true,
|
|
||||||
"email": true, "mail": true, "phone": true, "address": true,
|
|
||||||
"identity": true, "login": true, "user": true, "username": true, "account": true, "owner": true,
|
|
||||||
"uid": true, "gid": true, "puid": true, "pgid": true,
|
|
||||||
"password": true, "pass": true, "passwd": true, "passphrase": true, "secret": true, "token": true,
|
|
||||||
"key": true, "apikey": true, "bearer": true, "cert": true, "certificate": true, "credential": true,
|
|
||||||
"nameserver": true, "gateway": true, "subnet": true, "sender": true, "recipient": true, "contact": true,
|
|
||||||
"trusted": true, "whitelist": true, "peer": true, "bind": true, "listen": true, "upstream": true,
|
|
||||||
"proxy": true, "admin": true, "mac": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// operatorsCompounds are names of two words that are the operator's though neither word alone says so
|
|
||||||
// at the end of a key: a client's identifier, and a name the world knows a site or server by.
|
|
||||||
var operatorsCompounds = map[string]bool{
|
|
||||||
"client-id": true, "site-name": true, "server-name": true, "public-name": true, "smtp-relay": true,
|
|
||||||
"host-name": true, "user-name": true, "domain-name": true, "dns-server": true,
|
|
||||||
// Whom a rule lets in or keeps out: a list of addresses or networks.
|
|
||||||
"allow-from": true, "deny-from": true, "allow-list": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// aboutAnAmount are first words that make a key about how many or whether, never about whom:
|
|
||||||
// `max-tokens` is a number, `show-hostname` a switch. Not `allow` or `use`: `allow-from` and
|
|
||||||
// `use-host` name whom.
|
|
||||||
var aboutAnAmount = map[string]bool{
|
|
||||||
"max": true, "min": true, "num": true, "count": true, "show": true, "hide": true, "enable": true,
|
|
||||||
"disable": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// operatorsWord is what in a key's name says its value is the operator's, or "". A key is about its
|
|
||||||
// last word — `url-timeout` is a timeout, `user-agent` an agent, `mail-domain` a domain — or its last two
|
|
||||||
// as one of operatorsCompounds. A plural is read as its singular.
|
|
||||||
func operatorsWord(key string) string {
|
|
||||||
words := strings.FieldsFunc(key, func(r rune) bool { return r == '-' || r == '_' || r == '.' })
|
|
||||||
if len(words) == 0 || (len(words) > 1 && aboutAnAmount[words[0]]) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for i, w := range words {
|
|
||||||
switch {
|
|
||||||
case operatorsOwn[w]:
|
|
||||||
case strings.HasSuffix(w, "ies") && operatorsOwn[strings.TrimSuffix(w, "ies")+"y"]:
|
|
||||||
words[i] = strings.TrimSuffix(w, "ies") + "y"
|
|
||||||
case strings.HasSuffix(w, "s") && operatorsOwn[strings.TrimSuffix(w, "s")]:
|
|
||||||
words[i] = strings.TrimSuffix(w, "s")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n := len(words); n > 1 {
|
|
||||||
// Where a secret or an identity is kept is the operator's too: `password-file`, `token-path`.
|
|
||||||
if (words[n-1] == "file" || words[n-1] == "path") && operatorsOwn[words[n-2]] {
|
|
||||||
return words[n-2] + "-" + words[n-1]
|
|
||||||
}
|
|
||||||
for _, last := range []string{words[n-1], strings.TrimSuffix(words[n-1], "s")} {
|
|
||||||
if pair := words[n-2] + "-" + last; operatorsCompounds[pair] {
|
|
||||||
return pair
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if last := words[len(words)-1]; operatorsOwn[last] {
|
|
||||||
return last
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingProblems is every way a definition's setting defaults are wrong, in its own words.
|
|
||||||
func SettingProblems(m Manifest) []string {
|
|
||||||
if len(m.Settings) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
used := settingKeysUsedBy(m)
|
|
||||||
var problems []string
|
|
||||||
for _, key := range sortedSettingKeys(m.Settings) {
|
|
||||||
d := m.Settings[key]
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
if !settingRef.MatchString("${setting:" + key + "}") {
|
|
||||||
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if meshWords[key] {
|
|
||||||
say("the mesh reads %q itself, and a module gives it no default", key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if d.Kind != KindPreference {
|
|
||||||
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference)
|
|
||||||
}
|
|
||||||
if word := operatorsWord(key); word != "" {
|
|
||||||
say("a key naming %q is the operator's value, and has no default — it is the "+
|
|
||||||
"assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word)
|
|
||||||
}
|
|
||||||
switch v := d.Default.(type) {
|
|
||||||
case string:
|
|
||||||
if strings.TrimSpace(v) == "" {
|
|
||||||
say("an empty default is no default; give the value, or declare nothing")
|
|
||||||
}
|
|
||||||
case float64, bool:
|
|
||||||
case nil:
|
|
||||||
say("no default: a key without one is the operator's, and is not declared here")
|
|
||||||
default:
|
|
||||||
say("a default is a string, a number or a boolean, and this is %T", d.Default)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(d.Why) == "" {
|
|
||||||
say("no why: say in one sentence why this default")
|
|
||||||
}
|
|
||||||
if !used[key] {
|
|
||||||
say("nothing asks for ${setting:%s}, so the default reaches nothing", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults is the layer a module's own defaults make, or nothing when it gives none.
|
|
||||||
func Defaults(m Manifest) (Layer, bool) {
|
|
||||||
if len(m.Settings) == 0 {
|
|
||||||
return Layer{}, false
|
|
||||||
}
|
|
||||||
values := map[string]any{}
|
|
||||||
for key, d := range m.Settings {
|
|
||||||
if d.Default != nil {
|
|
||||||
values[key] = d.Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Layer{From: DefaultLayer, Values: values, Default: true}, len(values) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithDefaults is a module's layers with its defaults under them, for filling `${setting:<key>}`.
|
|
||||||
func WithDefaults(m Manifest, layers []Layer) []Layer {
|
|
||||||
d, has := Defaults(m)
|
|
||||||
if !has {
|
|
||||||
return layers
|
|
||||||
}
|
|
||||||
return append([]Layer{d}, layers...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingSource is one key's effective value and the layer it came from.
|
|
||||||
type SettingSource struct {
|
|
||||||
Key string
|
|
||||||
Value any
|
|
||||||
// From is DefaultLayer, MeshWideLayer or the node's name.
|
|
||||||
From string
|
|
||||||
// FromDefault is whether the value is the module's default, whatever From reads.
|
|
||||||
FromDefault bool
|
|
||||||
// Default is the module's default, when it gives one.
|
|
||||||
Default any
|
|
||||||
HasDefault bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effective is every key a module gives a default or a layer sets, with its value and where it came
|
|
||||||
// from: the default, then the mesh's layer, then the node's — later wins.
|
|
||||||
func Effective(m Manifest, layers []Layer) []SettingSource {
|
|
||||||
byKey := map[string]*SettingSource{}
|
|
||||||
for key, d := range m.Settings {
|
|
||||||
if d.Default == nil {
|
|
||||||
// A required config value has no default (ADR 0348 §4); a settings declaration always has one.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
|
|
||||||
Default: d.Default, HasDefault: true}
|
|
||||||
}
|
|
||||||
for _, layer := range layers {
|
|
||||||
// As the layer is said, placement too: `settings` answers it as a person set it.
|
|
||||||
for key, v := range layer.Whole() {
|
|
||||||
s, ok := byKey[key]
|
|
||||||
if !ok {
|
|
||||||
s = &SettingSource{Key: key}
|
|
||||||
byKey[key] = s
|
|
||||||
}
|
|
||||||
s.Value, s.From, s.FromDefault = v, layer.From, layer.Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]SettingSource, 0, len(byKey))
|
|
||||||
for _, s := range byKey {
|
|
||||||
out = append(out, *s)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedSettingKeys(in map[string]SettingDeclaration) []string {
|
|
||||||
keys := make([]string, 0, len(in))
|
|
||||||
for k := range in {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
|
|
||||||
//
|
|
||||||
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
|
|
||||||
// one installation and of no other, and that a module's software must be told. They had nowhere to
|
|
||||||
// live but the definition, which is how a catalogue meant for any mesh came to name this one
|
|
||||||
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
|
|
||||||
// operator answering.
|
|
||||||
//
|
|
||||||
// `${setting:<key>}` in a file's content, in a service's unit name and in a directory's owner (a uid or
|
|
||||||
// gid only, issue 279) is filled from the module's settings layers — the mesh's,
|
|
||||||
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
|
|
||||||
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
|
|
||||||
// naming the key and the remedy: a definition that carried a default for a mail domain would be
|
|
||||||
// carrying the very literal this removes, and a blank written silently would be a service that
|
|
||||||
// comes up wrong somewhere that names neither the module nor the key.
|
|
||||||
|
|
||||||
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
|
|
||||||
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
|
|
||||||
|
|
||||||
// settingsUsed is every key a file's content asks for, once each, in order of first use.
|
|
||||||
func settingsUsed(content string) []string {
|
|
||||||
var keys []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range settingRef.FindAllStringSubmatch(content, -1) {
|
|
||||||
if !seen[m[1]] {
|
|
||||||
seen[m[1]] = true
|
|
||||||
keys = append(keys, m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnsetSettingError is a module whose definition says ${setting:<key>} where nothing sets that key: typed, so
|
|
||||||
// that whoever reads why a module was left out of a machine can tell a setting nobody gave — the operator's
|
|
||||||
// to give, named with the command that gives it — from any other reason (novox/hq issue 380). Its words are
|
|
||||||
// the refusal's, unchanged.
|
|
||||||
type UnsetSettingError struct {
|
|
||||||
Module, Setting string
|
|
||||||
said string
|
|
||||||
// where says what asks for it — "a file", "a service whose unit", "a directory <id> whose owner" — and
|
|
||||||
// setToday which of the module's keys a layer sets, for saying it again in the config form's words.
|
|
||||||
where, setToday string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *UnsetSettingError) Error() string { return e.said }
|
|
||||||
|
|
||||||
// unsetInConfigWords is the refusal of a required config value nothing sets, in the words of a module written in the
|
|
||||||
// config form (novox/hq ADR 0348 §1, §4): `${config:<key>}` and the config verb, never `settings set`. Any other
|
|
||||||
// error, and any error of a module in the settings form, is returned as it was.
|
|
||||||
func unsetInConfigWords(err error, m Manifest) error {
|
|
||||||
var unset *UnsetSettingError
|
|
||||||
if err == nil || !m.DeclaresConfig() || !errors.As(err, &unset) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return &UnsetSettingError{Module: unset.Module, Setting: unset.Setting, where: unset.where,
|
|
||||||
setToday: unset.setToday, said: fmt.Sprintf(
|
|
||||||
"%s has %s that says ${config:%s}, and nothing sets the required config value %q for it — a "+
|
|
||||||
"required value is the operator's own and has no default (novox/hq ADR 0348 §4): set it with "+
|
|
||||||
"the config verb, for %s on this node, key %q%s",
|
|
||||||
unset.Module, unset.where, unset.Setting, unset.Setting, unset.Module, unset.Setting,
|
|
||||||
strings.ReplaceAll(unset.setToday, "no setting is set", "no config value is set"))}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
|
|
||||||
//
|
|
||||||
// The last layer setting a key wins, which is the node's over the mesh's over the module's own
|
|
||||||
// default (novox/hq ADR 0262) — the same order settle applies to a mergeable file. The caller lays
|
|
||||||
// the defaults under the layers with WithDefaults. A value that is not a string is written the way a program would read
|
|
||||||
// it (a number without a trailing .000000, a boolean as true/false).
|
|
||||||
func settingInto(resource map[string]any, layers []Layer, module string) error {
|
|
||||||
if fmt.Sprint(resource["type"]) == "service" {
|
|
||||||
return settingIntoUnit(resource, layers, module)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(resource["type"]) == "directory" {
|
|
||||||
return settingIntoOwner(resource, layers, module)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, key := range settingsUsed(content) {
|
|
||||||
value, set := settingValue(layers, key)
|
|
||||||
if !set {
|
|
||||||
return &UnsetSettingError{Module: module, Setting: key, where: "a file",
|
|
||||||
setToday: orNoSettings(layers), said: fmt.Sprintf(
|
|
||||||
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
|
||||||
"value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+
|
|
||||||
"preference has a default in the definition (ADR 0262): "+
|
|
||||||
"`settings set %s <file>` with {%q: …}%s",
|
|
||||||
module, key, key, module, key, orNoSettings(layers))}
|
|
||||||
}
|
|
||||||
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
|
|
||||||
}
|
|
||||||
resource["content"] = content
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unitPart is what a setting may put into a unit's name: the characters systemd allows in a unit name,
|
|
||||||
// less the instance's `@` and the escape's `\`, and at least one of them. Anything else — a space, a slash,
|
|
||||||
// a newline that would begin a directive in the unit file the name ends up in — is refused, never written.
|
|
||||||
var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`)
|
|
||||||
|
|
||||||
// unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template,
|
|
||||||
// after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can
|
|
||||||
// make the unit another instance of the same template and nothing else (third review of mesh-catalog #147).
|
|
||||||
var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`)
|
|
||||||
|
|
||||||
// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting
|
|
||||||
// anywhere but as a template's whole instance.
|
|
||||||
func UnitSettingProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "service" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
unit, _ := r["unit"].(string)
|
|
||||||
if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !unitInstance.MatchString(unit) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+
|
|
||||||
"instance; a setting may stand only as the whole instance, after the @ and before the suffix "+
|
|
||||||
"(name@${setting:key}.timer)", m.Module, r["id"], unit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the
|
|
||||||
// distribution's timer for the pool the operator names cannot write the pool into its definition
|
|
||||||
// (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a
|
|
||||||
// unit's name; the resource is left as it was.
|
|
||||||
func settingIntoUnit(resource map[string]any, layers []Layer, module string) error {
|
|
||||||
unit, ok := resource["unit"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, key := range settingsUsed(unit) {
|
|
||||||
value, set := settingValue(layers, key)
|
|
||||||
if !set {
|
|
||||||
return &UnsetSettingError{Module: module, Setting: key, where: "a service whose unit",
|
|
||||||
setToday: orNoSettings(layers), said: fmt.Sprintf(
|
|
||||||
"%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
|
||||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
|
||||||
"`settings set %s <file>` with {%q: …}%s",
|
|
||||||
module, key, key, module, key, orNoSettings(layers))}
|
|
||||||
}
|
|
||||||
v := plainly(value)
|
|
||||||
if !unitPart.MatchString(v) {
|
|
||||||
return fmt.Errorf("%s: the setting %q is %q, which cannot be the instance of the unit %s: an instance "+
|
|
||||||
"takes letters, digits, ':', '_', '.' and '-', does not begin with '-' (a systemctl option), and is "+
|
|
||||||
"at most 64 characters", module, key, v, unit)
|
|
||||||
}
|
|
||||||
unit = strings.ReplaceAll(unit, "${setting:"+key+"}", v)
|
|
||||||
}
|
|
||||||
resource["unit"] = unit
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279).
|
|
||||||
//
|
|
||||||
// A containerised module runs as the identity its assignment sets per node — its puid and pgid
|
|
||||||
// settings, written into the environment file its image reads (ADR 0112, issue 153) — and a directory
|
|
||||||
// its app must write, beyond the one its image re-owns at start, was owned by whatever the manifest
|
|
||||||
// spelt: right on a machine whose identity matched the image's default, refused by the app on one whose
|
|
||||||
// did not. So a directory's `owner` may name the settings that identity is: `${setting:<key>}` for the
|
|
||||||
// user alone, `${setting:<user>}:${setting:<group>}` for both, filled from the same layers at
|
|
||||||
// composition.
|
|
||||||
//
|
|
||||||
// **Whole numbers only.** An owner a setting fills is a uid or a gid — never an account's name, never
|
|
||||||
// text — so no value can put anything into the ownership the node-engine applies but a number; one that
|
|
||||||
// is not is refused at composition, naming the key, and the directory is left as it was. The shape is
|
|
||||||
// judged where the manifest is read (OwnerSettingProblems): each side of the colon a whole placeholder
|
|
||||||
// or a whole number, and every key one the module itself asks for elsewhere — in a file, a unit, a
|
|
||||||
// contribution or a served fact — or declares a default for, so the owner follows the identity its
|
|
||||||
// process is told it runs as, never a key that reaches nothing else.
|
|
||||||
|
|
||||||
// ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number.
|
|
||||||
var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)`
|
|
||||||
|
|
||||||
// ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group.
|
|
||||||
var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`)
|
|
||||||
|
|
||||||
// wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id"
|
|
||||||
// (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys).
|
|
||||||
func wholeID(v string) (uint64, bool) {
|
|
||||||
if v == "" || strings.TrimLeft(v, "0123456789") != "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
n, err := strconv.ParseUint(v, 10, 32)
|
|
||||||
return n, err == nil && n < 1<<32-1
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting
|
|
||||||
// in any shape but a whole placeholder or number on each side of the colon, or names a key the module
|
|
||||||
// asks for nowhere else and declares no default for.
|
|
||||||
func OwnerSettingProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
asked := settingKeysAskedOutsideOwners(m)
|
|
||||||
for k := range m.Settings {
|
|
||||||
asked[k] = true
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
owner, _ := r["owner"].(string)
|
|
||||||
if !strings.Contains(owner, "${setting:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
|
||||||
// Said by the placeholder sweep: only a directory's owner takes a setting.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !ownerWithSetting.MatchString(owner) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+
|
|
||||||
"the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+
|
|
||||||
"${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)",
|
|
||||||
m.Module, r["id"], owner))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// A template's text is not here yet, so what the module asks for elsewhere cannot be known: WithTemplatesFrom
|
|
||||||
// parses its result again with every template filled, and this half is judged then (as config.go skips its
|
|
||||||
// "placed nowhere" rule while m.Templated()).
|
|
||||||
if m.Templated() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, key := range settingsUsed(owner) {
|
|
||||||
if !asked[key] {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+
|
|
||||||
"module asks for %q nowhere else and declares no default for it: an owner follows the identity "+
|
|
||||||
"the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+
|
|
||||||
"own (novox/hq issue 279)", m.Module, r["id"], key, key, key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the
|
|
||||||
// resource left as it was — a key nothing sets and a value that is not a whole number.
|
|
||||||
func settingIntoOwner(resource map[string]any, layers []Layer, module string) error {
|
|
||||||
owner, ok := resource["owner"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, key := range settingsUsed(owner) {
|
|
||||||
value, set := settingValue(layers, key)
|
|
||||||
if !set {
|
|
||||||
return &UnsetSettingError{Module: module, Setting: key,
|
|
||||||
where: fmt.Sprintf("a directory %v whose owner", resource["id"]), setToday: orNoSettings(layers),
|
|
||||||
said: fmt.Sprintf(
|
|
||||||
"%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
|
||||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
|
||||||
"`settings set %s <file>` with {%q: …}%s",
|
|
||||||
module, resource["id"], key, key, module, key, orNoSettings(layers))}
|
|
||||||
}
|
|
||||||
v := plainly(value)
|
|
||||||
id, whole := wholeID(v)
|
|
||||||
if !whole {
|
|
||||||
return fmt.Errorf("%s: the setting %q is %q, which cannot own the directory %v: an owner a setting "+
|
|
||||||
"names is a uid or a gid, a whole number, never an account's name or other text (novox/hq issue 279)",
|
|
||||||
module, key, v, resource["id"])
|
|
||||||
}
|
|
||||||
// Written as the number parsed, so "0001" reaches the node-engine as 1, never as text.
|
|
||||||
owner = strings.ReplaceAll(owner, "${setting:"+key+"}", strconv.FormatUint(id, 10))
|
|
||||||
}
|
|
||||||
resource["owner"] = owner
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func settingValue(layers []Layer, key string) (any, bool) {
|
|
||||||
var value any
|
|
||||||
set := false
|
|
||||||
for _, layer := range layers {
|
|
||||||
if v, has := layer.Values[key]; has {
|
|
||||||
value, set = v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value, set
|
|
||||||
}
|
|
||||||
|
|
||||||
func orNoSettings(layers []Layer) string {
|
|
||||||
var keys []string
|
|
||||||
for _, l := range layers {
|
|
||||||
if l.Default {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for k := range l.Values {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(keys) == 0 {
|
|
||||||
return "; no setting is set for this module"
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return "; set today: " + strings.Join(keys, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served
|
|
||||||
// facts ask for, so a setting that lands in one is not called stray.
|
|
||||||
func settingKeysUsedBy(m Manifest) map[string]bool {
|
|
||||||
used := settingKeysAskedOutsideOwners(m)
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
if owner, ok := r["owner"].(string); ok {
|
|
||||||
for _, k := range settingsUsed(owner) {
|
|
||||||
used[k] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return used
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts
|
|
||||||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
|
||||||
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
|
||||||
used := map[string]bool{}
|
|
||||||
note := func(s string) {
|
|
||||||
for _, k := range settingsUsed(s) {
|
|
||||||
used[k] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
switch fmt.Sprint(r["type"]) {
|
|
||||||
case "file":
|
|
||||||
if content, ok := r["content"].(string); ok {
|
|
||||||
note(content)
|
|
||||||
}
|
|
||||||
case "service":
|
|
||||||
if unit, ok := r["unit"].(string); ok {
|
|
||||||
note(unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inValues := func(values map[string]any) {
|
|
||||||
for _, v := range values {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
note(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, values := range m.Contributes {
|
|
||||||
inValues(values)
|
|
||||||
}
|
|
||||||
for _, locals := range m.ContributesMany {
|
|
||||||
for _, values := range locals {
|
|
||||||
inValues(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, values := range m.Serves {
|
|
||||||
inValues(values)
|
|
||||||
}
|
|
||||||
return used
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A mergeable file names the setting keys it takes (novox/hq ADR 0326, issue 168).
|
|
||||||
//
|
|
||||||
// Before this, every key of a module's layers merged into every one of its mergeable files: the
|
|
||||||
// mesh's own words, a key meant for a `${setting:<key>}` in another file, a key for a second
|
|
||||||
// configuration file. So a module could not own two configurable files, and every receiver had to
|
|
||||||
// ignore what it did not know. ADR 0164 §11 decided that a setting names the files that read it; this
|
|
||||||
// is that relation, stated from the file's side, so the operator's own values stay declared nowhere
|
|
||||||
// (ADR 0262 §2).
|
|
||||||
|
|
||||||
// TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes.
|
|
||||||
const TakesSettingsField = "takes-settings"
|
|
||||||
|
|
||||||
// **A taken value may be laid under the file's own name** (issue 751). An entry of takes-config is a key, laid
|
|
||||||
// under that key, or {"key": <the config key>, "as": <the file's name for it>}: the key stays a config key,
|
|
||||||
// lower-case, and the file is written in its software's spelling, `atlassian-url` as ATLASSIAN_URL. Like every
|
|
||||||
// taken value it is laid only when a layer sets it, never from a default, so an unset one leaves the file's bytes
|
|
||||||
// and the code's own fallback (its environment) as they were; for that reason it is never `required`
|
|
||||||
// (ConfigProblems). The settings form has no such entry: it is the config form's alone.
|
|
||||||
|
|
||||||
// Fields of a renamed takes-config entry.
|
|
||||||
const (
|
|
||||||
takenKeyField = "key"
|
|
||||||
takenAsField = "as"
|
|
||||||
)
|
|
||||||
|
|
||||||
// takenEntry is one value a mergeable file takes: its key and the name the file has for it.
|
|
||||||
type takenEntry struct {
|
|
||||||
key, as string
|
|
||||||
renamed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenEntries is the entries of a takes-settings list in their order, a usable entry only: a string is a key
|
|
||||||
// under its own name, an object with exactly key and as a key under another (TakenSettingsProblems says the rest).
|
|
||||||
func takenEntries(raw any) []takenEntry {
|
|
||||||
list, _ := raw.([]any)
|
|
||||||
var out []takenEntry
|
|
||||||
for _, item := range list {
|
|
||||||
if e, ok := asTakenEntry(item); ok {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// asTakenEntry reads one item of a takes-settings list, and whether it is shaped as an entry at all.
|
|
||||||
func asTakenEntry(item any) (takenEntry, bool) {
|
|
||||||
switch v := item.(type) {
|
|
||||||
case string:
|
|
||||||
return takenEntry{key: v, as: v}, true
|
|
||||||
case map[string]any:
|
|
||||||
key, keyOK := v[takenKeyField].(string)
|
|
||||||
as, asOK := v[takenAsField].(string)
|
|
||||||
if !keyOK || !asOK || len(v) != 2 {
|
|
||||||
return takenEntry{}, false
|
|
||||||
}
|
|
||||||
return takenEntry{key: key, as: as, renamed: true}, true
|
|
||||||
}
|
|
||||||
return takenEntry{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them.
|
|
||||||
func takenKeyList(resource map[string]any) []string {
|
|
||||||
var keys []string
|
|
||||||
for _, e := range takenEntries(resource[TakesSettingsField]) {
|
|
||||||
keys = append(keys, e.key)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenKeys is the keys a resource names in takes-settings, each with the name the file has for it, and whether
|
|
||||||
// it names any list at all. An empty list is a list: that file takes no setting.
|
|
||||||
func takenKeys(resource map[string]any) (map[string]string, bool) {
|
|
||||||
raw, said := resource[TakesSettingsField]
|
|
||||||
if !said {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
keys := map[string]string{}
|
|
||||||
for _, e := range takenEntries(raw) {
|
|
||||||
keys[e.key] = e.as
|
|
||||||
}
|
|
||||||
return keys, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlyTaken is the layers with every key the file does not name left out, and each key it takes under the
|
|
||||||
// file's name for it. The file's own defaults and what the mesh reads itself are not touched here: settle
|
|
||||||
// already keeps both out of a file.
|
|
||||||
//
|
|
||||||
// **A layer said before its module moved still reaches the file** (issue 751): such a layer names a renamed value
|
|
||||||
// by the file's own spelling (nodered's timeZone on one machine), and until K5 moves the layer and renames the key
|
|
||||||
// that spelling is taken as the value's, so moving the module changes no machine's file. Where one layer says
|
|
||||||
// both, the config key wins.
|
|
||||||
func onlyTaken(layers []Layer, taken map[string]string) []Layer {
|
|
||||||
oldSpelling := map[string]string{}
|
|
||||||
for key, as := range taken {
|
|
||||||
if as != key {
|
|
||||||
oldSpelling[as] = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept := make([]Layer, 0, len(layers))
|
|
||||||
for _, layer := range layers {
|
|
||||||
values := map[string]any{}
|
|
||||||
for key, value := range layer.Values {
|
|
||||||
if as, takes := taken[key]; takes {
|
|
||||||
values[as] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if newKey, old := oldSpelling[key]; old {
|
|
||||||
if _, said := layer.Values[newKey]; !said {
|
|
||||||
values[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
|
||||||
}
|
|
||||||
return kept
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenNames is every key a layer may say for the values a resource takes: each config key and, for a renamed
|
|
||||||
// one, the file's own spelling a layer said before its module moved (onlyTaken).
|
|
||||||
func takenNames(resource map[string]any) []string {
|
|
||||||
var names []string
|
|
||||||
for _, e := range takenEntries(resource[TakesSettingsField]) {
|
|
||||||
names = append(names, e.key)
|
|
||||||
if e.renamed {
|
|
||||||
names = append(names, e.as)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true
|
|
||||||
// only when it has one and every one carries takes-settings. A module with a silent mergeable file
|
|
||||||
// keeps today's form (§3), where every key reaches every mergeable file.
|
|
||||||
func namesItsKeys(m Manifest) bool {
|
|
||||||
merging := 0
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if how, _ := r["merge"].(string); how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
merging++
|
|
||||||
if _, said := r[TakesSettingsField]; !said {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merging > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in
|
|
||||||
// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh
|
|
||||||
// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another
|
|
||||||
// that names none.
|
|
||||||
func TakenSettingsProblems(m Manifest) []string {
|
|
||||||
return takenSettingsProblems(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module
|
|
||||||
// check, `module add`, a build taken in, the builder and the merge gate's registration — and never
|
|
||||||
// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back):
|
|
||||||
// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue
|
|
||||||
// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts
|
|
||||||
// file named its keys, must still compose and still be put back; only its next change is held to it.
|
|
||||||
//
|
|
||||||
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
|
|
||||||
// names every file that does not, and a file is not reported twice.
|
|
||||||
func WrittenSettingsProblems(m Manifest) []string {
|
|
||||||
if len(m.Settings) == 0 || m.DeclaresConfig() {
|
|
||||||
// A module in the config form is held to this where it is read, in its own words (ConfigProblems).
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var silent []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
how, _ := r["merge"].(string)
|
|
||||||
if how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, said := r[TakesSettingsField]; said {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
silent = append(silent, fmt.Sprint(r["id"]))
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, id := range silent {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+
|
|
||||||
"a module that declares a setting names the keys each mergeable file takes, an empty list "+
|
|
||||||
"for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and
|
|
||||||
// where a stored one is composed.
|
|
||||||
func takenSettingsProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
var naming, silent []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
how, _ := r["merge"].(string)
|
|
||||||
raw, said := r[TakesSettingsField]
|
|
||||||
if how == "" {
|
|
||||||
if said {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s says %s and does not merge: only a mergeable "+
|
|
||||||
"file takes settings by key (novox/hq ADR 0326)", m.Module, id, TakesSettingsField))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !said {
|
|
||||||
silent = append(silent, id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
naming = append(naming, id)
|
|
||||||
list, isList := raw.([]any)
|
|
||||||
if !isList {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s: %s is a list of setting keys, and this is %T",
|
|
||||||
m.Module, id, TakesSettingsField, raw))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
protected := map[string]bool{}
|
|
||||||
for _, k := range stringsOf(r["protected"]) {
|
|
||||||
protected[k] = true
|
|
||||||
}
|
|
||||||
seen, landed := map[string]bool{}, map[string]bool{}
|
|
||||||
for _, item := range list {
|
|
||||||
if _, isObject := item.(map[string]any); isObject {
|
|
||||||
e, shaped := asTakenEntry(item)
|
|
||||||
switch {
|
|
||||||
case !m.DeclaresConfig():
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s lays a value under another name, "+
|
|
||||||
"which only the config form says: write the key alone (novox/hq issue 751)",
|
|
||||||
m.Module, id, item, TakesSettingsField))
|
|
||||||
continue
|
|
||||||
case !shaped:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is a key alone, or an object "+
|
|
||||||
"saying exactly %q and %q: the config key and the file's name for it (novox/hq issue 751)",
|
|
||||||
m.Module, id, item, TakesSettingsField, takenKeyField, takenAsField))
|
|
||||||
continue
|
|
||||||
case !settingRef.MatchString("${setting:" + e.key + "}"):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s: %q in %s is not a usable key: a config "+
|
|
||||||
"key is lower-case letters, digits, dots, dashes and underscores; the file's own spelling "+
|
|
||||||
"is its %q", m.Module, id, e.key, TakesSettingsField, takenAsField))
|
|
||||||
continue
|
|
||||||
case !usableKey(e.as):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s: %q, the name %s takes %q as, is not a "+
|
|
||||||
"usable name in a file", m.Module, id, e.as, TakesSettingsField, e.key))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, takenEntryProblems(m.Module, id, e, seen, landed, protected)...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, isString := item.(string)
|
|
||||||
if !isString || !usableKey(key) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key",
|
|
||||||
m.Module, id, item, TakesSettingsField))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, takenEntryProblems(m.Module, id, takenEntry{key: key, as: key}, seen,
|
|
||||||
landed, protected)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(naming) > 0 {
|
|
||||||
for _, id := range silent {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s merges and names no %s while %s does: once one "+
|
|
||||||
"mergeable file of a module names the keys it takes, every one does, an empty list for none "+
|
|
||||||
"(novox/hq ADR 0326)", m.Module, id, TakesSettingsField, strings.Join(naming, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenEntryProblems is what is wrong with one usable entry of a file's takes-settings, against those before it:
|
|
||||||
// a key taken twice, or two values laid under one name in the file; a word the mesh reads itself, as the key or as
|
|
||||||
// the file's name; a name the file keeps as protected. seen and landed are the keys and the names so far, added to.
|
|
||||||
func takenEntryProblems(module, id string, e takenEntry, seen, landed, protected map[string]bool) []string {
|
|
||||||
var problems []string
|
|
||||||
switch {
|
|
||||||
case seen[e.key]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s", module, id, e.key, TakesSettingsField))
|
|
||||||
case landed[e.as]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s, as a key or as the name a key is "+
|
|
||||||
"laid under: one name in a file holds one value", module, id, e.as, TakesSettingsField))
|
|
||||||
case meshWords[e.key] || meshWords[e.as]:
|
|
||||||
word := e.key
|
|
||||||
if !meshWords[word] {
|
|
||||||
word = e.as
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+
|
|
||||||
"(novox/hq ADR 0164 §14)", module, id, word, TakesSettingsField, word))
|
|
||||||
case protected[e.as]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+
|
|
||||||
"key is the file's or the setting's, never both", module, id, e.as, TakesSettingsField))
|
|
||||||
}
|
|
||||||
seen[e.key], landed[e.as] = true, true
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// usableKey is a key a file can be said to take: not empty, and no space, line end or control
|
|
||||||
// character. Not the lower-case rule of a `${setting:<key>}`: a file's own keys are its software's
|
|
||||||
// spelling, such as `timeZone`.
|
|
||||||
func usableKey(key string) bool {
|
|
||||||
if key == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range key {
|
|
||||||
if unicode.IsSpace(r) || unicode.IsControl(r) || !unicode.IsPrint(r) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// takenSaid is what a stray key's message adds for a module whose files name their keys: which keys
|
|
||||||
// each file takes, so the person sees where the key would have had to be named.
|
|
||||||
func takenSaid(m Manifest) string {
|
|
||||||
if !namesItsKeys(m) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var files []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if how, _ := r["merge"].(string); how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys := takenKeyList(r)
|
|
||||||
if len(keys) == 0 {
|
|
||||||
files = append(files, fmt.Sprintf("%v takes none", r["id"]))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
files = append(files, fmt.Sprintf("%v takes %s", r["id"], strings.Join(keys, ", ")))
|
|
||||||
}
|
|
||||||
return " (" + strings.Join(files, "; ") + ")"
|
|
||||||
}
|
|
||||||
@@ -1,655 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settings are how somebody changes a config file a module owns, without editing it.
|
|
||||||
//
|
|
||||||
// Managed files are generated and never edited (novox/hq ADR 0011), so a person's intention has
|
|
||||||
// to be expressed somewhere the generator can see it. Then the file is produced from the module's
|
|
||||||
// defaults and that intention together, and upstream can rewrite its half freely.
|
|
||||||
//
|
|
||||||
// **An override beats a default, so there is nothing to resolve.** When the module changes a key
|
|
||||||
// somebody has set, the setting wins — it was a statement about that key, made deliberately, and
|
|
||||||
// the default was only ever what to do in the absence of one. This is the same shape as the rest
|
|
||||||
// of the mesh refusing to guess: there is no guess to make.
|
|
||||||
|
|
||||||
// Merge is how a file's content is combined with settings. A module names one per resource.
|
|
||||||
const (
|
|
||||||
// MergeJSON treats the content as a JSON document and merges settings into it by key.
|
|
||||||
MergeJSON = "json"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Layer is one source of settings, in the order they are applied.
|
|
||||||
type Layer struct {
|
|
||||||
// Where these came from, for saying which layer set a value.
|
|
||||||
From string
|
|
||||||
Values map[string]any
|
|
||||||
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
|
|
||||||
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
|
|
||||||
Default bool
|
|
||||||
// Placement is the assignment's placement at this layer's scope — `places`, `accesses`, `networks`,
|
|
||||||
// `endpoints`, `expose`, `reach`, `ports` — which the controller holds apart from settings (novox/hq
|
|
||||||
// ADR 0348 clause 11, issues 651 and 736): read by their readers only, and never from Values. LayerOf
|
|
||||||
// splits a layer as a person says it into the two.
|
|
||||||
Placement map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplySettings produces a resource's final content from the module's own and the layers over it.
|
|
||||||
//
|
|
||||||
// Layers are applied in order, so a later one beats an earlier one — the mesh's settings for a
|
|
||||||
// module first, then this node's. A node that differs from the rest is expressed by differing,
|
|
||||||
// rather than by repeating everything the rest already say.
|
|
||||||
func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, error) {
|
|
||||||
// Today's form reaches a mergeable file with every key of the layer as it was said, placement too
|
|
||||||
// (novox/hq ADR 0326 §3, ADR 0348 clause 11: a module not yet moved keeps it), so a file composes
|
|
||||||
// byte for byte as it did while placement was kept in the layer.
|
|
||||||
layers = asSaid(layers)
|
|
||||||
how, _ := resource["merge"].(string)
|
|
||||||
taken, names := takenKeys(resource)
|
|
||||||
if how == "" {
|
|
||||||
if names {
|
|
||||||
// Refused by the module check too (TakenSettingsProblems); said here as well, for a
|
|
||||||
// definition stored by an older binary, rather than handed to the host, which knows no
|
|
||||||
// such field.
|
|
||||||
return nil, fmt.Errorf("%v says %s and does not merge: only a mergeable file takes "+
|
|
||||||
"settings by key (novox/hq ADR 0326)", resource["id"], TakesSettingsField)
|
|
||||||
}
|
|
||||||
// Not a mergeable file. Settings for it are ignored rather than silently doing nothing
|
|
||||||
// somewhere else — see UnusedSettings, which is what says so.
|
|
||||||
return resource, nil
|
|
||||||
}
|
|
||||||
if names {
|
|
||||||
// **The file takes the keys it names, and no other** (novox/hq ADR 0326, issue 168): a key
|
|
||||||
// for another file, for a `${setting:<key>}` or for the mesh itself does not land here.
|
|
||||||
layers = onlyTaken(layers, taken)
|
|
||||||
}
|
|
||||||
if how != MergeJSON {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%v says it merges as %q, and this control plane knows how to merge %q",
|
|
||||||
resource["id"], how, MergeJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, _ := resource["content"].(string)
|
|
||||||
var base map[string]any
|
|
||||||
if strings.TrimSpace(content) == "" {
|
|
||||||
base = map[string]any{}
|
|
||||||
} else if err := json.Unmarshal([]byte(content), &base); err != nil {
|
|
||||||
return nil, fmt.Errorf("%v says it merges as JSON and its content is not JSON: %w",
|
|
||||||
resource["id"], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected := map[string]bool{}
|
|
||||||
for _, k := range stringsOf(resource["protected"]) {
|
|
||||||
protected[k] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
merged, err := settle(base, layers, protected, fmt.Sprint(resource["id"]))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, v := range resource {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
// Keys sorted by the encoder, so the same settings always produce the same bytes. A file
|
|
||||||
// whose lines move for no reason makes every reconcile look like a change, and anything
|
|
||||||
// reflecting it would restart for ever.
|
|
||||||
rendered, err := json.MarshalIndent(merged, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out["content"] = string(rendered) + "\n"
|
|
||||||
delete(out, "merge")
|
|
||||||
delete(out, "protected")
|
|
||||||
delete(out, TrustedField)
|
|
||||||
delete(out, TakesSettingsField)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settle fills what a provider serves from its settings. Exported because a served fact is settled
|
|
||||||
// where the mesh is walked rather than where a node is declared.
|
|
||||||
//
|
|
||||||
// **What a provider serves is its definition's; a layer replaces none of it** (novox/hq ADR 0348
|
|
||||||
// clause 11, issue 696). A served value is filled from the layers only where the definition asks,
|
|
||||||
// with `${setting:<key>}` (ADR 0155) — a mail provider's domain, an identity provider's issuer, a
|
|
||||||
// time-series store's org and bucket — and a layer that sets a key the provider serves as a literal
|
|
||||||
// changes nothing a consumer is told. Nor does a setting add a key (novox/hq 04-ISSUES/173): a
|
|
||||||
// setting made for one of the provider's files — a site name, a public address — is not part of the
|
|
||||||
// contract. Until issue 696, a layer replaced any served key it named; influxdb on ace was the one
|
|
||||||
// layer relying on it, and serves `${setting:org}` and `${setting:bucket}` since.
|
|
||||||
func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
|
|
||||||
return filledFromSettings(deepCopy(base), layers, "what is served")
|
|
||||||
}
|
|
||||||
|
|
||||||
// overridden lays settings over a contribution: a map whose keys are its contract. Only the keys the
|
|
||||||
// map already declares are touched; the rest of a layer is somebody else's business (a file's,
|
|
||||||
// another destination's) and is left to reach it there.
|
|
||||||
//
|
|
||||||
// A declared value may itself be the operator's, `${setting:<key>}` (ADR 0155), filled after the
|
|
||||||
// overrides (filledFromSettings).
|
|
||||||
func overridden(base map[string]any, layers []Layer, what string) (map[string]any, error) {
|
|
||||||
kept := make([]Layer, 0, len(layers))
|
|
||||||
for _, layer := range layers {
|
|
||||||
values := map[string]any{}
|
|
||||||
for key, value := range layer.Values {
|
|
||||||
if _, declared := base[key]; declared {
|
|
||||||
values[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
|
||||||
}
|
|
||||||
merged, err := settle(base, kept, nil, what)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return filledFromSettings(merged, layers, what)
|
|
||||||
}
|
|
||||||
|
|
||||||
// filledFromSettings fills each `${setting:<key>}` in a map's top-level strings from the layers,
|
|
||||||
// later beating earlier and a module's defaults lowest, and refuses one nothing sets by name — a
|
|
||||||
// literal placeholder handed to a consumer is a service configured against a string nobody meant.
|
|
||||||
// It fills merged in place and returns it.
|
|
||||||
func filledFromSettings(merged map[string]any, layers []Layer, what string) (map[string]any, error) {
|
|
||||||
for key, value := range merged {
|
|
||||||
s, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, asked := range settingsUsed(s) {
|
|
||||||
v, set := settingValue(layers, asked)
|
|
||||||
if !set {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+
|
|
||||||
"assignment's, never the definition's (novox/hq ADR 0112)%s",
|
|
||||||
what, asked, key, asked, orNoSettings(layers))
|
|
||||||
}
|
|
||||||
s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v))
|
|
||||||
}
|
|
||||||
merged[key] = s
|
|
||||||
}
|
|
||||||
return merged, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// settle lays the layers over a module's own values, in order.
|
|
||||||
//
|
|
||||||
// Shared by a file's content and a module's contributions, because they are the same act: the
|
|
||||||
// module says what it means by default, and somebody says what it means here. A contribution that
|
|
||||||
// could not be settled would have to be edited to be reused anywhere else. The two differ in one
|
|
||||||
// respect, and the caller decides it: a file takes keys it did not declare (a setting may add to a
|
|
||||||
// configuration), a contribution does not (overridden). A served fact takes no layer at all; only
|
|
||||||
// its ${setting:<key>} is filled (Settle).
|
|
||||||
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
|
|
||||||
map[string]any, error) {
|
|
||||||
merged := deepCopy(base)
|
|
||||||
for _, layer := range layers {
|
|
||||||
if layer.Default {
|
|
||||||
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
|
|
||||||
// file's content already is its defaults, and a contribution or served fact declares its
|
|
||||||
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for key, value := range layer.Values {
|
|
||||||
if key == PortsSetting {
|
|
||||||
// Where the machine puts a port is the mesh's to apply, not a value for a file or
|
|
||||||
// for what a consumer is told (novox/hq ADR 0100); it reaches both as the port.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if protected[key] {
|
|
||||||
// The module said it must own this one. Refused rather than ignored: a setting
|
|
||||||
// that is quietly dropped is somebody believing they changed something.
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
|
|
||||||
layer.From, key, what, key)
|
|
||||||
}
|
|
||||||
// **No layer replaces a secret the vault fills, at any depth** (novox/hq issue 610): a
|
|
||||||
// mergeable file's `${secret:<name>}` is the vault's value, and a layer that reached it —
|
|
||||||
// `server.secret_key` under a `server` the file takes — would put a value the operator or an
|
|
||||||
// agent chose where the vault's belongs. Judged against the file's own content, so it holds
|
|
||||||
// whatever a module's `protected` list says.
|
|
||||||
if at := secretReplacedBy(base[key], value, key); at != "" {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s sets %q on %v, which the vault fills (${secret:…}); a secret comes from the vault, "+
|
|
||||||
"never from a setting (novox/hq issues 342, 610)", layer.From, at, what)
|
|
||||||
}
|
|
||||||
merged[key] = mergeValue(merged[key], value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeValue combines one value with the one over it.
|
|
||||||
//
|
|
||||||
// Two objects merge key by key, so setting one field of a nested block does not delete its
|
|
||||||
// siblings. Anything else is replaced whole: a list that merged element-wise could neither be
|
|
||||||
// shortened nor reordered, and there is no correct guess about which element is "the same one".
|
|
||||||
func mergeValue(under, over any) any {
|
|
||||||
underMap, isUnderMap := under.(map[string]any)
|
|
||||||
overMap, isOverMap := over.(map[string]any)
|
|
||||||
if !isUnderMap || !isOverMap {
|
|
||||||
return over
|
|
||||||
}
|
|
||||||
merged := deepCopy(underMap)
|
|
||||||
for k, v := range overMap {
|
|
||||||
merged[k] = mergeValue(merged[k], v)
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
func deepCopy(in map[string]any) map[string]any {
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, v := range in {
|
|
||||||
if nested, ok := v.(map[string]any); ok {
|
|
||||||
out[k] = deepCopy(nested)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingsHoldOneLine refuses a line break, a carriage return, any other line end (lineEnds) or a NUL in any
|
|
||||||
// string of any setting, for every
|
|
||||||
// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is
|
|
||||||
// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind
|
|
||||||
// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL
|
|
||||||
// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a
|
|
||||||
// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file
|
|
||||||
// of the module's own, never a setting — with one exception, the certificate authority's root as certificates
|
|
||||||
// (certificates), because that is how step-ca serves it.
|
|
||||||
func settingsHoldOneLine(module string, layers []Layer) error {
|
|
||||||
for _, layer := range layers {
|
|
||||||
for _, key := range sortedKeysAny(layer.Values) {
|
|
||||||
if module == rootModule && key == rootSetting {
|
|
||||||
if text, ok := layer.Values[key].(string); ok && certificates(text) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if at := lineBreakIn(layer.Values[key], key); at != "" {
|
|
||||||
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return, another line end or a NUL, which a "+
|
|
||||||
"file it is written into would read as a line of its own; a setting is one line (novox/hq "+
|
|
||||||
"issue 339)", module, at, layer.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// lineEnds are every character a reader may take as the end of a line: \n and \r, vertical tab and form feed,
|
|
||||||
// NEL, and the Unicode line and paragraph separators; and NUL, which ends a string early wherever C reads it.
|
|
||||||
const lineEnds = "\n\r\v\f\x00\u0085\u2028\u2029"
|
|
||||||
|
|
||||||
// rootSetting is the one setting that may hold lines: the certificate authority's root, which step-ca serves to
|
|
||||||
// its consumers as the setting `root` and which they write into a bundle file (the co-located path, issue 038).
|
|
||||||
const (
|
|
||||||
rootModule = "step-ca"
|
|
||||||
rootSetting = "root"
|
|
||||||
)
|
|
||||||
|
|
||||||
// certificates says whether a value is one or more PEM CERTIFICATE blocks and nothing else: each decodes with
|
|
||||||
// encoding/pem, carries no headers, and parses with x509.ParseCertificate; nothing but a final line break
|
|
||||||
// surrounds them, and every line ends with \n alone.
|
|
||||||
func certificates(v string) bool {
|
|
||||||
if strings.ContainsAny(v, "\r\v\f\x00\u0085\u2028\u2029") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
rest := []byte(v)
|
|
||||||
found := 0
|
|
||||||
for len(rest) > 0 {
|
|
||||||
if !bytes.HasPrefix(rest, []byte("-----BEGIN ")) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
block, after := pem.Decode(rest)
|
|
||||||
if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) > 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
found++
|
|
||||||
rest = after
|
|
||||||
}
|
|
||||||
return found > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "".
|
|
||||||
func lineBreakIn(v any, at string) string {
|
|
||||||
if strings.ContainsAny(at, lineEnds) {
|
|
||||||
return strconv.Quote(at)
|
|
||||||
}
|
|
||||||
switch t := v.(type) {
|
|
||||||
case string:
|
|
||||||
if strings.ContainsAny(t, lineEnds) {
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
for _, k := range sortedKeysAny(t) {
|
|
||||||
if found := lineBreakIn(t[k], at+"."+k); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for i, e := range t {
|
|
||||||
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedKeysAny(m map[string]any) []string {
|
|
||||||
out := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnusedSettings names settings that reach nothing.
|
|
||||||
//
|
|
||||||
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
|
|
||||||
// nothing — and would find out by the machine not behaving differently, which is the slowest
|
|
||||||
// way there is. This is what makes that visible at the moment they set it.
|
|
||||||
//
|
|
||||||
// Where a key can land: any mergeable file takes any key; a file asking for `${setting:<key>}`
|
|
||||||
// takes that key (ADR 0155); a contribution takes a key it declares, and no other (novox/hq
|
|
||||||
// 04-ISSUES/173); a served fact takes only the keys it asks for as `${setting:<key>}`, since a layer
|
|
||||||
// replaces no served key (novox/hq issue 696); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` —
|
|
||||||
// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped.
|
|
||||||
func UnusedSettings(m Manifest, layers []Layer) []string {
|
|
||||||
named := namesItsKeys(m)
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if how, _ := r["merge"].(string); how != "" && !named {
|
|
||||||
// Today's form (novox/hq ADR 0326 §3, ADR 0164 §15): a mergeable file that names no
|
|
||||||
// keys takes every key, so nothing is stray.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A computed module has no resources here to look at — they are worked out per node, and
|
|
||||||
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
|
|
||||||
// claiming every setting on the private network is stray would be worse than saying nothing.
|
|
||||||
if m.Computed != "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lands := settingKeysUsedBy(m)
|
|
||||||
// A mergeable file takes the keys it names (novox/hq ADR 0326 §5).
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
for _, key := range takenNames(r) {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
|
||||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
|
||||||
for key := range m.Settings {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
for _, values := range m.Contributes {
|
|
||||||
for key := range values {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, locals := range m.ContributesMany {
|
|
||||||
for _, values := range locals {
|
|
||||||
for key := range values {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var unused []string
|
|
||||||
for _, layer := range layers {
|
|
||||||
for key := range layer.Whole() {
|
|
||||||
if lands[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `expose` is a real destination for a module that listens: it overrides a port's
|
|
||||||
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
|
|
||||||
if key == ExposeSetting && len(m.Listens) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `ports` gives a module's port a machine port on one node (novox/hq ADR 0100),
|
|
||||||
// validated in GivenPorts, so it is not stray here either.
|
|
||||||
if key == PortsSetting {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `reach` says how far one of this module's endpoints reaches (novox/hq ADR 0138) — the
|
|
||||||
// filter's source, which names are composed, and therefore which authority certifies
|
|
||||||
// them. Validated in Reaches, so not stray.
|
|
||||||
if key == ReachSetting && len(m.Listens) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `endpoints` configures a module's endpoints by name — the machine port, the subdomain and
|
|
||||||
// the reach as one block each (novox/hq ADR 0138). Validated in Endpoints, so not stray.
|
|
||||||
if key == EndpointsSetting && len(m.Listens) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `places` puts a declared directory where this machine keeps it, `accesses` says where
|
|
||||||
// the operator's data is (novox/hq issue 153). Validated in Places and AccessPlaces.
|
|
||||||
if key == PlacesSetting && len(directoriesOf(m)) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if key == AccessesSetting && len(m.Accesses) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `networks` keeps a found network for a taken container on one adopted machine
|
|
||||||
// (novox/hq ADR 0163). Validated in KeptNetworks, so not stray.
|
|
||||||
if key == NetworksSetting {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
unused = append(unused, fmt.Sprintf(
|
|
||||||
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
|
||||||
"declares no %q in what it contributes%s",
|
|
||||||
layer.From, key, m.Module, key, key, takenSaid(m)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(unused)
|
|
||||||
return unused
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringsOf(v any) []string {
|
|
||||||
raw, ok := v.([]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, item := range raw {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// NetworksSetting is the settings key that keeps a found network for a taken container, on one
|
|
||||||
// adopted machine (novox/hq ADR 0163, rule 4):
|
|
||||||
//
|
|
||||||
// {"networks": {"server": ["predecessor_default"]}}
|
|
||||||
//
|
|
||||||
// has the module's container `server` also join `predecessor_default` once taken, so a neighbour
|
|
||||||
// that resolves it by name on that network keeps resolving it. Migration scaffolding in the sense
|
|
||||||
// of ADR 0104: assigned only on an adopted machine, reported while it stands, removed when the
|
|
||||||
// neighbours are taken. Keyed by the container's resource id; the value is the networks it keeps.
|
|
||||||
const NetworksSetting = "networks"
|
|
||||||
|
|
||||||
// KeptNetworks reads which found networks each of a module's containers keeps, by container id.
|
|
||||||
//
|
|
||||||
// Refused from a mesh-wide layer — a found network is a fact about one machine — for an id the
|
|
||||||
// module declares no container under, for a name that is not a network's, and on a machine that
|
|
||||||
// is not adopted: the setting exists so neighbours the mesh has not taken yet keep reaching the
|
|
||||||
// container, and a converged machine has no such neighbours.
|
|
||||||
func KeptNetworks(m Manifest, layers []Layer, adopted bool) (map[string][]string, error) {
|
|
||||||
containers := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "container" {
|
|
||||||
containers[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := map[string][]string{}
|
|
||||||
for _, layer := range layers {
|
|
||||||
raw, ok := layer.Placement[NetworksSetting]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if layer.From == MeshWideLayer {
|
|
||||||
return nil, fmt.Errorf("%s: %s is given per node — a found network is a fact about one "+
|
|
||||||
"machine; set it with --node", m.Module, NetworksSetting)
|
|
||||||
}
|
|
||||||
if !adopted {
|
|
||||||
return nil, fmt.Errorf("%s: %s keeps a found network for neighbours the mesh has not taken "+
|
|
||||||
"yet, and %s is converged — nothing on it is found; clear the setting", m.Module,
|
|
||||||
NetworksSetting, layer.From)
|
|
||||||
}
|
|
||||||
blocks, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a { container: [network, …] } map, and %q set it to "+
|
|
||||||
"something else", m.Module, NetworksSetting, layer.From)
|
|
||||||
}
|
|
||||||
for id, body := range blocks {
|
|
||||||
if !containers[id] {
|
|
||||||
return nil, fmt.Errorf("%s: %s names the container %q, which it does not declare — "+
|
|
||||||
"the setting reaches nothing; it declares %s", m.Module, NetworksSetting, id,
|
|
||||||
orNothing(sortedKeys(containers)))
|
|
||||||
}
|
|
||||||
names := stringsOf(body)
|
|
||||||
if len(names) == 0 {
|
|
||||||
return nil, fmt.Errorf("%s: %s for %q is a list of network names, and %q set it to %v",
|
|
||||||
m.Module, NetworksSetting, id, layer.From, body)
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if !networkName.MatchString(n) {
|
|
||||||
return nil, fmt.Errorf("%s: %s for %q names %q, which is not a network name",
|
|
||||||
m.Module, NetworksSetting, id, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
out[id] = names
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// networkName is what a container runtime accepts as a network's name.
|
|
||||||
var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
|
||||||
|
|
||||||
// JudgeSettings composes a module's settings against its definition and refuses the first thing
|
|
||||||
// that cannot work, naming the module, the layer and the key (novox/hq ADR 0163, rule 6).
|
|
||||||
//
|
|
||||||
// **The same judgement where a setting is stored and where a machine is declared.** Stored, a
|
|
||||||
// setting that cannot compose is refused before it is kept; composed later, a definition that has
|
|
||||||
// moved under a stored setting leaves that module out of the machine's declaration rather than
|
|
||||||
// the machine without one. Every reader of settings runs here: a port given, an exposure, a reach,
|
|
||||||
// an endpoint, a placement, an access, a kept network, a mergeable file's keys and a file's
|
|
||||||
// `${setting:…}`. A key that reaches nothing is not here: it cannot break a composition, so it is
|
|
||||||
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
|
|
||||||
// and never costs a module its place.
|
|
||||||
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
|
||||||
// Placement is judged for line ends as it always was: a path is written into files as well.
|
|
||||||
if err := settingsHoldOneLine(m.Module, asSaid(layers)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Which keys each mergeable file takes is the definition's to say right (novox/hq ADR 0326);
|
|
||||||
// judged here too for a definition stored by an older binary.
|
|
||||||
if problems := takenSettingsProblems(m); len(problems) > 0 {
|
|
||||||
return fmt.Errorf("%s", problems[0])
|
|
||||||
}
|
|
||||||
// With no layers too: a definition may ask for a setting nobody made — an access placed by
|
|
||||||
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
|
|
||||||
if _, err := GivenPorts(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := Reaches(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := Endpoints(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := Places(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, _, err := accessesFor(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := KeptNetworks(m, layers, adopted); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
settled, err := ApplySettings(r, layers)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
copied := map[string]any{}
|
|
||||||
for k, v := range settled {
|
|
||||||
copied[k] = v
|
|
||||||
}
|
|
||||||
if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, layers), m.Module), m); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretReplacedBy is the path in a mergeable file's own content that a layer's value would replace while the
|
|
||||||
// vault fills it — a string holding `${secret:` — or "" when the value leaves every such string as it is
|
|
||||||
// (novox/hq issue 610). Maps merge key by key (mergeValue), so only the keys the value names are judged; any
|
|
||||||
// other value replaces the whole of what it lands on.
|
|
||||||
func secretReplacedBy(base, value any, at string) string {
|
|
||||||
if baseMap, isMap := base.(map[string]any); isMap {
|
|
||||||
if valueMap, also := value.(map[string]any); also {
|
|
||||||
keys := make([]string, 0, len(valueMap))
|
|
||||||
for k := range valueMap {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
if found := secretReplacedBy(baseMap[k], valueMap[k], at+"."+k); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return secretIn(base, at)
|
|
||||||
}
|
|
||||||
|
|
||||||
// secretIn is the first path under v holding a vault placeholder, or "".
|
|
||||||
func secretIn(v any, at string) string {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case string:
|
|
||||||
if strings.Contains(t, "${secret:") {
|
|
||||||
return at
|
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
keys := make([]string, 0, len(t))
|
|
||||||
for k := range t {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
if found := secretIn(t[k], at+"."+k); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for i, item := range t {
|
|
||||||
if found := secretIn(item, fmt.Sprintf("%s[%d]", at, i)); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
// A machine's shares, and the shares a machine mounts (novox/hq ADR 0263).
|
|
||||||
//
|
|
||||||
// **Two roles, one per side of the wire.** A machine that shares a directory with the mesh does it
|
|
||||||
// through the holder of `node-nfs-server`: it writes the machine's export file, runs the NFS service,
|
|
||||||
// opens its port to the private network only, and provides each share as the provision `nfs-share`. A
|
|
||||||
// machine that wants the files gets them through the holder of `node-mounts`: it writes a mount unit and
|
|
||||||
// an automount unit per share, so nothing mounts at boot and nothing can fail a boot, and it says a
|
|
||||||
// device that comes and goes is absent rather than failed.
|
|
||||||
//
|
|
||||||
// **One holder per machine on each side.** Two modules writing one machine's export file, or two writing
|
|
||||||
// mount units for one mount point, is the conflict a seat exists to refuse. Both seats deliver nothing:
|
|
||||||
// `nfs-share` is provided at the mesh's scope, by the module holding `node-nfs-server` on the machine that
|
|
||||||
// shares, and a seat at a node's scope cannot be the answer for a provision at the mesh's.
|
|
||||||
//
|
|
||||||
// **The data is the operator's** (ADR 0051). Neither holder creates, chowns or removes anything under a
|
|
||||||
// shared path; the export maps every client to the path's owner, so no client acts as another account on
|
|
||||||
// the server. Their verbs read, and the ones that act take over what a person wrote by hand only on a
|
|
||||||
// person's word: a dataset's export property, an fstab line.
|
|
||||||
|
|
||||||
// NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263).
|
|
||||||
const NFSServerSeat = "node-nfs-server"
|
|
||||||
|
|
||||||
// MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263).
|
|
||||||
const MountsSeat = "node-mounts"
|
|
||||||
|
|
||||||
// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263).
|
|
||||||
func nfsServerVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
|
||||||
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
|
||||||
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
|
||||||
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
|
|
||||||
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
|
|
||||||
"/etc/exports.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
|
||||||
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
|
||||||
"knows its clients.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
|
|
||||||
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
|
|
||||||
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
|
|
||||||
"own private address, when the server grants it the share and the node asks for it. What a machine " +
|
|
||||||
"mounting the share asks before it mounts.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"share": "the share, by its name",
|
|
||||||
"address": "a node's private address, to ask whether the share is exported to it (optional)",
|
|
||||||
}, []string{"share"}),
|
|
||||||
Replaces: []string{"showmount -e"}},
|
|
||||||
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
|
|
||||||
"-ra) and answer the shares as it then holds them. The module's own process writes its export " +
|
|
||||||
"file; this is for after a change made outside it. Changes no shared path.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"exportfs -ra"}},
|
|
||||||
{Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " +
|
|
||||||
"property for a path the mesh's own export now serves — only when that export is live. Without " +
|
|
||||||
"confirm, says what it would do and changes nothing.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"path": "the shared path whose hand-made export is taken over",
|
|
||||||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
|
||||||
"why": "why, for the record",
|
|
||||||
}, []string{"path"}, "confirm"),
|
|
||||||
Replaces: []string{"zfs set sharenfs=off"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263).
|
|
||||||
func mountsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " +
|
|
||||||
"automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " +
|
|
||||||
"and which settings asked for it. A password in a mount's options is never shown.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}},
|
|
||||||
{Name: "test", Description: "Whether one share's or occasional source's server answers from this " +
|
|
||||||
"machine now, without touching its mount point.",
|
|
||||||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
|
||||||
Replaces: []string{"showmount -e", "ping"}},
|
|
||||||
{Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " +
|
|
||||||
"access.",
|
|
||||||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
|
||||||
Replaces: []string{"mount"}},
|
|
||||||
{Name: "unmount", Description: "Release one share or occasional source now; its automount stays " +
|
|
||||||
"armed, so the next access mounts it again.",
|
|
||||||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
|
||||||
Replaces: []string{"umount"}},
|
|
||||||
{Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " +
|
|
||||||
"line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " +
|
|
||||||
"the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"mountpoint": "the mount point whose fstab line is taken over",
|
|
||||||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
|
||||||
"why": "why, for the record",
|
|
||||||
}, []string{"mountpoint"}, "confirm"),
|
|
||||||
Replaces: []string{"sed -i /etc/fstab"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
|
|
||||||
//
|
|
||||||
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
|
|
||||||
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
|
|
||||||
// mesh derives the bucket from the two names, so the module and the local name must each be one
|
|
||||||
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
|
|
||||||
// never derive one bucket.
|
|
||||||
|
|
||||||
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
|
|
||||||
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
// The mesh's caps on what a module may ask of a bucket's history.
|
|
||||||
const (
|
|
||||||
// StateMostHistory is the most past values a key may keep. The server's own limit.
|
|
||||||
StateMostHistory = 64
|
|
||||||
)
|
|
||||||
|
|
||||||
// StateDeclaration is one bucket a module owns: its local name, and the options that are the
|
|
||||||
// owner's to choose, as a seat chooses how long its backlog survives (design 32 §3).
|
|
||||||
type StateDeclaration struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// History is how many values a key keeps, the current one included; zero is one.
|
|
||||||
History int `json:"history,omitempty"`
|
|
||||||
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
|
|
||||||
TTLSeconds int `json:"ttl-seconds,omitempty"`
|
|
||||||
// PerMachine says the state is keyed by machine: each machine's instance writes the key named for
|
|
||||||
// its machine and no other, and the bus grants it that key alone (novox/hq ADR 0260).
|
|
||||||
PerMachine bool `json:"per-machine,omitempty"`
|
|
||||||
// KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker
|
|
||||||
// when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message.
|
|
||||||
KeyTTL bool `json:"key-ttl,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
|
|
||||||
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
|
||||||
trimmed := bytes.TrimSpace(raw)
|
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
|
||||||
return json.Unmarshal(trimmed, &s.Name)
|
|
||||||
}
|
|
||||||
type plain StateDeclaration
|
|
||||||
var full plain
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&full); err != nil {
|
|
||||||
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
*s = StateDeclaration(full)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
|
||||||
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
|
||||||
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL {
|
|
||||||
return json.Marshal(s.Name)
|
|
||||||
}
|
|
||||||
type plain StateDeclaration
|
|
||||||
return json.Marshal(plain(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadState splits a read into the owning module and the local name, or says why it is not one.
|
|
||||||
func ReadState(read string) (module, local string, err error) {
|
|
||||||
at := strings.LastIndex(read, ".")
|
|
||||||
if at <= 0 || at == len(read)-1 {
|
|
||||||
return "", "", fmt.Errorf("%q does not name a module and its state: a read is <module>.<name>", read)
|
|
||||||
}
|
|
||||||
module, local = read[:at], read[at+1:]
|
|
||||||
if !stateName.MatchString(module) {
|
|
||||||
return "", "", fmt.Errorf("%q cannot own state: a module whose state is read is one plain name", module)
|
|
||||||
}
|
|
||||||
if !stateName.MatchString(local) {
|
|
||||||
return "", "", fmt.Errorf("%q is not a state name: lower-case letters, digits and hyphens", local)
|
|
||||||
}
|
|
||||||
return module, local, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateProblems is what is wrong with a manifest's state and reads.
|
|
||||||
//
|
|
||||||
// Refused at registration, because a bucket name the bus cannot hold is a module that installs,
|
|
||||||
// starts, and is refused on its first write with a reason about a bucket nobody named.
|
|
||||||
func StateProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
if len(m.State) > 0 && !stateName.MatchString(m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps state, and a module's name is part of its buckets' names, which take one plain "+
|
|
||||||
"name — no dot (novox/hq ADR 0201)", m.Module))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, s := range m.State {
|
|
||||||
switch {
|
|
||||||
case !stateName.MatchString(s.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+
|
|
||||||
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name))
|
|
||||||
case seen[s.Name]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
seen[s.Name] = true
|
|
||||||
if s.History < 0 || s.History > StateMostHistory {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory))
|
|
||||||
}
|
|
||||||
if s.TTLSeconds < 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Reads {
|
|
||||||
module, _, err := ReadState(r)
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if module == m.Module {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControllerMirrors are the controller's record mirrors a module may read, each `mesh-controller.<name>`
|
|
||||||
// (novox/hq ADR 0289, design 54 §5, design 57 F3). **The controller keeps them, not its manifest's `state`**:
|
|
||||||
// they are asserted with the controller's own buckets, with options of their own, and written by the
|
|
||||||
// controller's account alone (the writers table); declared as a module's state, they would be asserted a
|
|
||||||
// second way and granted to the module's own principal as well. Named here so a read of one is a read of
|
|
||||||
// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing.
|
|
||||||
var ControllerMirrors = []string{"assignments", "declared-config", "predefined", "builds", "rollouts", "checks-running"}
|
|
||||||
|
|
||||||
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
|
||||||
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
|
||||||
// state it reads, as a consumer may before its emitter (design 32 §1).
|
|
||||||
func StateReadsNothingDeclares(manifests []Manifest) []string {
|
|
||||||
declared := map[string]map[string]bool{}
|
|
||||||
for _, m := range manifests {
|
|
||||||
own := map[string]bool{}
|
|
||||||
for _, s := range m.State {
|
|
||||||
own[s.Name] = true
|
|
||||||
}
|
|
||||||
declared[m.Module] = own
|
|
||||||
}
|
|
||||||
if own, present := declared[ControllerSeatName]; present {
|
|
||||||
for _, name := range ControllerMirrors {
|
|
||||||
own[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, m := range manifests {
|
|
||||||
for _, r := range m.Reads {
|
|
||||||
module, local, err := ReadState(r)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if own, present := declared[module]; present && !own[local] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,632 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module's own stream (novox/hq ADR 0363).
|
|
||||||
//
|
|
||||||
// A module declares a stream as it declares a log (ADR 0297) or a bucket (ADR 0201): named locally, the
|
|
||||||
// events it carries named locally as `emits` names them, its age and its size within the mesh's bounds.
|
|
||||||
// The controller creates it and stays the only writer of stream definitions. A stream may be restricted
|
|
||||||
// by a trusted config value of its own module: then a consumer hears a part of it only where its own
|
|
||||||
// `consumes` asks and that value names it.
|
|
||||||
|
|
||||||
// The mesh's bounds on a module's stream: its age in days and its size in MiB, and what it gets when it
|
|
||||||
// says nothing.
|
|
||||||
const (
|
|
||||||
StreamLeastDays = 1
|
|
||||||
StreamMostDays = 30
|
|
||||||
StreamDefaultDays = 7
|
|
||||||
StreamLeastMiB = 1
|
|
||||||
StreamMostMiB = 8192
|
|
||||||
StreamDefaultMiB = 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// GrantKey is the name in a grant's pattern that each key of the config value fills.
|
|
||||||
const GrantKey = "{key}"
|
|
||||||
|
|
||||||
// StreamRetentionWorkQueue is a stream's declared retention (novox/hq ADR 0436 §6): a message is removed
|
|
||||||
// once a consumer acknowledges it, and the bus refuses a new one rather than dropping what it holds. Empty
|
|
||||||
// is the mesh's own limits retention — kept until it ages or the stream fills, dropping its oldest.
|
|
||||||
const StreamRetentionWorkQueue = "work-queue"
|
|
||||||
|
|
||||||
// StreamLeastDeliveries and StreamMostDeliveries bound a work-queue stream's max-deliveries (novox/hq ADR
|
|
||||||
// 0436 §6): how many times a message is handed over before it is given up as a dead letter.
|
|
||||||
const (
|
|
||||||
StreamLeastDeliveries = 1
|
|
||||||
StreamMostDeliveries = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
// ReservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream
|
|
||||||
// may take: a seat's work queue, a log, a bucket, an object store, and the dead letters.
|
|
||||||
var ReservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
|
|
||||||
|
|
||||||
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
|
|
||||||
// package cannot import; a test keeps them agreeing.
|
|
||||||
var MeshStreamNames = []string{"CONTROL", "NODES", "ASSIGNMENTS", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
|
|
||||||
|
|
||||||
// StreamDeclaration is one stream a module owns.
|
|
||||||
type StreamDeclaration struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// Events are the events it carries, named locally; `*` is one name and `**`, last only, the rest.
|
|
||||||
Events []string `json:"events"`
|
|
||||||
// MaxAgeDays and MaxMiB bound it; zero is the default.
|
|
||||||
MaxAgeDays int `json:"max-age-days,omitempty"`
|
|
||||||
MaxMiB int `json:"max-mib,omitempty"`
|
|
||||||
// Grant restricts it, when said.
|
|
||||||
Grant *StreamGrant `json:"grant,omitempty"`
|
|
||||||
// Retention is how the stream decides what to keep when it fills (novox/hq ADR 0436 §6): empty for the
|
|
||||||
// mesh's own limits retention (dropping its oldest), or StreamRetentionWorkQueue.
|
|
||||||
Retention string `json:"retention,omitempty"`
|
|
||||||
// MaxDeliveries bounds how many times a work-queue message is handed over, 1 to 10; refused without
|
|
||||||
// retention work-queue (novox/hq ADR 0436 §6).
|
|
||||||
MaxDeliveries int `json:"max-deliveries,omitempty"`
|
|
||||||
// Duplicates is a work-queue stream's duplicate window in hours, at most its maximum age; refused
|
|
||||||
// without retention work-queue (novox/hq ADR 0436 §6).
|
|
||||||
Duplicates int `json:"duplicates,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamGrant says which trusted config value of the module grants a consumer a part of the stream, and
|
|
||||||
// which part each of its keys names.
|
|
||||||
type StreamGrant struct {
|
|
||||||
// Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the
|
|
||||||
// controller's terminal or on the operator's Approve.
|
|
||||||
Config string `json:"config"`
|
|
||||||
// Events is a pattern holding GrantKey exactly once, which each key of the config value fills.
|
|
||||||
Events string `json:"events"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads a stream strictly: an unknown key is refused here, not passed over.
|
|
||||||
func (s *StreamDeclaration) UnmarshalJSON(raw []byte) error {
|
|
||||||
type plain StreamDeclaration
|
|
||||||
var p plain
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&p); err != nil {
|
|
||||||
return fmt.Errorf("a stream is {name, events, max-age-days, max-mib, grant, retention, max-deliveries, "+
|
|
||||||
"duplicates}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
*s = StreamDeclaration(p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads a grant strictly.
|
|
||||||
func (g *StreamGrant) UnmarshalJSON(raw []byte) error {
|
|
||||||
type plain StreamGrant
|
|
||||||
var p plain
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&p); err != nil {
|
|
||||||
return fmt.Errorf("a stream's grant is {config, events}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
*g = StreamGrant(p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Days is the stream's age in days, the default where none is said.
|
|
||||||
func (s StreamDeclaration) Days() int {
|
|
||||||
if s.MaxAgeDays == 0 {
|
|
||||||
return StreamDefaultDays
|
|
||||||
}
|
|
||||||
return s.MaxAgeDays
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap is the stream's size in MiB, the default where none is said.
|
|
||||||
func (s StreamDeclaration) Cap() int {
|
|
||||||
if s.MaxMiB == 0 {
|
|
||||||
return StreamDefaultMiB
|
|
||||||
}
|
|
||||||
return s.MaxMiB
|
|
||||||
}
|
|
||||||
|
|
||||||
// BusName is the stream's name on the bus: its local name in capitals, a hyphen an underscore.
|
|
||||||
func (s StreamDeclaration) BusName() string { return StreamBusName(s.Name) }
|
|
||||||
|
|
||||||
// StreamBusName is a local stream name as the bus holds it.
|
|
||||||
func StreamBusName(local string) string {
|
|
||||||
return strings.ToUpper(strings.ReplaceAll(local, "-", "_"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carries says whether an event, or every event a pattern names, is one this stream carries: within one
|
|
||||||
// of its patterns.
|
|
||||||
func (s StreamDeclaration) Carries(pattern string) bool {
|
|
||||||
for _, p := range s.Events {
|
|
||||||
if within(pattern, p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest.
|
|
||||||
func StreamProblems(m Manifest) []string {
|
|
||||||
if len(m.Streams) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
if !stateName.MatchString(m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+
|
|
||||||
"name — no dot (novox/hq ADR 0363)", m.Module))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
terminal := TerminalKeys(m)
|
|
||||||
for i, s := range m.Streams {
|
|
||||||
switch {
|
|
||||||
case !stateName.MatchString(s.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+
|
|
||||||
"(novox/hq ADR 0363)", m.Module, s.Name))
|
|
||||||
case seen[s.BusName()]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name))
|
|
||||||
case slices.Contains(MeshStreamNames, s.BusName()):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName()))
|
|
||||||
default:
|
|
||||||
for _, prefix := range ReservedStreamPrefixes {
|
|
||||||
if strings.HasPrefix(s.BusName(), prefix) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+
|
|
||||||
"with (novox/hq ADR 0363)", m.Module, s.Name, prefix))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[s.BusName()] = true
|
|
||||||
if s.MaxAgeDays != 0 && (s.MaxAgeDays < StreamLeastDays || s.MaxAgeDays > StreamMostDays) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+
|
|
||||||
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, StreamLeastDays, StreamMostDays))
|
|
||||||
}
|
|
||||||
if s.MaxMiB != 0 && (s.MaxMiB < StreamLeastMiB || s.MaxMiB > StreamMostMiB) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+
|
|
||||||
"(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, StreamLeastMiB, StreamMostMiB))
|
|
||||||
}
|
|
||||||
// A stream may declare retention: work-queue (novox/hq ADR 0436 §6): a message removed once a
|
|
||||||
// consumer acknowledges it, the bus refusing a new one when full rather than dropping its oldest, with
|
|
||||||
// max-deliveries (1 to 10) and a duplicate window (duplicates, hours, at most its maximum age) -- both
|
|
||||||
// meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted.
|
|
||||||
switch s.Retention {
|
|
||||||
case "", StreamRetentionWorkQueue:
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)",
|
|
||||||
m.Module, s.Name, s.Retention, StreamRetentionWorkQueue))
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case s.Retention == StreamRetentionWorkQueue &&
|
|
||||||
(s.MaxDeliveries < StreamLeastDeliveries || s.MaxDeliveries > StreamMostDeliveries):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q declares max-deliveries %d; a work-queue stream's max-deliveries is between %d "+
|
|
||||||
"and %d (novox/hq ADR 0436 §6)", m.Module, s.Name, s.MaxDeliveries, StreamLeastDeliveries,
|
|
||||||
StreamMostDeliveries))
|
|
||||||
case s.Retention != StreamRetentionWorkQueue && s.MaxDeliveries != 0:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q declares max-deliveries %d without retention %q (novox/hq ADR 0436 §6)",
|
|
||||||
m.Module, s.Name, s.MaxDeliveries, StreamRetentionWorkQueue))
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case s.Duplicates != 0 && s.Retention != StreamRetentionWorkQueue:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q declares duplicates %d hours without retention %q (novox/hq ADR 0436 §6)",
|
|
||||||
m.Module, s.Name, s.Duplicates, StreamRetentionWorkQueue))
|
|
||||||
case s.Duplicates != 0 && s.Duplicates > s.Days()*24:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q keeps duplicates %d hours, longer than its maximum age of %d days (novox/hq "+
|
|
||||||
"ADR 0436 §6)", m.Module, s.Name, s.Duplicates, s.Days()))
|
|
||||||
}
|
|
||||||
if s.Retention == StreamRetentionWorkQueue && s.Grant != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q declares retention %q carrying a grant; a work-queue stream is read by its "+
|
|
||||||
"module's one consumer, never granted (novox/hq ADR 0436 §6)", m.Module, s.Name, s.Retention))
|
|
||||||
}
|
|
||||||
if len(s.Events) == 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
for _, e := range s.Events {
|
|
||||||
if err := eventPattern(e, false); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams
|
|
||||||
// carrying one event, would leave the module's runtime to choose.
|
|
||||||
for _, e := range m.EmitsAll() {
|
|
||||||
if s.Carries(e) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+
|
|
||||||
"so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, other := range m.Streams[i+1:] {
|
|
||||||
for _, a := range s.Events {
|
|
||||||
for _, b := range other.Events {
|
|
||||||
if patternsOverlap(a, b) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+
|
|
||||||
"ADR 0363)", m.Module, s.Name, other.Name, a, b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if g := s.Grant; g != nil {
|
|
||||||
if !slices.Contains(terminal, g.Config) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+
|
|
||||||
"the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+
|
|
||||||
"operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config))
|
|
||||||
}
|
|
||||||
if strings.Count(g.Events, GrantKey) != 1 {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)",
|
|
||||||
m.Module, s.Name, g.Events, GrantKey))
|
|
||||||
} else if err := eventPattern(g.Events, true); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key,
|
|
||||||
// GrantKey is a name too.
|
|
||||||
func eventPattern(pattern string, key bool) error {
|
|
||||||
if pattern == "" {
|
|
||||||
return fmt.Errorf("a pattern needs a name")
|
|
||||||
}
|
|
||||||
parts := strings.Split(pattern, ".")
|
|
||||||
for i, part := range parts {
|
|
||||||
switch {
|
|
||||||
case part == TheRest:
|
|
||||||
if i != len(parts)-1 {
|
|
||||||
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
|
|
||||||
}
|
|
||||||
case part == OneName:
|
|
||||||
case key && part == GrantKey:
|
|
||||||
case !eventName.MatchString(part):
|
|
||||||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamsHearing is every stream of a module that carries some of a consumed pattern's events, and
|
|
||||||
// whether one of them carries all of them.
|
|
||||||
func (m Manifest) StreamsHearing(pattern string) (hearing []StreamDeclaration, whole bool) {
|
|
||||||
for _, s := range m.Streams {
|
|
||||||
for _, p := range s.Events {
|
|
||||||
if patternsOverlap(pattern, p) {
|
|
||||||
hearing = append(hearing, s)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.Carries(pattern) {
|
|
||||||
whole = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hearing, whole
|
|
||||||
}
|
|
||||||
|
|
||||||
// within says every event pattern a names is one b names too.
|
|
||||||
func within(a, b string) bool {
|
|
||||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
switch {
|
|
||||||
case i == len(y):
|
|
||||||
return i == len(x)
|
|
||||||
case y[i] == TheRest:
|
|
||||||
return i < len(x)
|
|
||||||
case i == len(x):
|
|
||||||
return false
|
|
||||||
case x[i] == TheRest:
|
|
||||||
return false
|
|
||||||
case y[i] == OneName:
|
|
||||||
continue
|
|
||||||
case x[i] == OneName:
|
|
||||||
return false
|
|
||||||
case x[i] != y[i]:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// patternsOverlap says some event both patterns name.
|
|
||||||
func patternsOverlap(a, b string) bool {
|
|
||||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
switch {
|
|
||||||
case i == len(x) && i == len(y):
|
|
||||||
return true
|
|
||||||
case i == len(x) || i == len(y):
|
|
||||||
return false
|
|
||||||
case x[i] == TheRest || y[i] == TheRest:
|
|
||||||
return true
|
|
||||||
case x[i] == OneName || y[i] == OneName || x[i] == y[i]:
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkQueueStreamProblems refuses a work-queue stream (novox/hq ADR 0436 §6) that is not read by exactly
|
|
||||||
// one process of its own module: named by none, it queues forever unconsumed; named by two, each would take
|
|
||||||
// half of what the other needed, silently.
|
|
||||||
func WorkQueueStreamProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
named := map[string]int{}
|
|
||||||
for _, b := range m.ProcessBuses() {
|
|
||||||
for _, q := range b.Queues {
|
|
||||||
named[q]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range m.Streams {
|
|
||||||
if s.Retention != StreamRetentionWorkQueue {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch named[s.Name] {
|
|
||||||
case 0:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's work-queue stream %q: no process names it in bus.queues; a work-queue stream is read by "+
|
|
||||||
"exactly one process (novox/hq ADR 0436 §6)", m.Module, s.Name))
|
|
||||||
case 1:
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's work-queue stream %q: %d processes name it in bus.queues; a work-queue stream is read by "+
|
|
||||||
"exactly one (novox/hq ADR 0436 §6)", m.Module, s.Name, named[s.Name]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkQueueConsumedElsewhereProblems refuses another module's `consumes` naming a work-queue stream's events
|
|
||||||
// (novox/hq ADR 0436 §6): read by its module's one process alone, never broadcast to a consumer that only
|
|
||||||
// subscribes. Modelled on RestrictedHearingProblems.
|
|
||||||
func WorkQueueConsumedElsewhereProblems(shelf Shelf) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
m := shelf[module]
|
|
||||||
for _, c := range m.Consumes {
|
|
||||||
emitter, rest, named := strings.Cut(c, ".")
|
|
||||||
provider, known := shelf[emitter]
|
|
||||||
if !named || emitter == OneName || emitter == TheRest || !known || emitter == module {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hearing, _ := provider.StreamsHearing(rest)
|
|
||||||
for _, s := range hearing {
|
|
||||||
if s.Retention != StreamRetentionWorkQueue {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s consumes %s, which %s's work-queue stream %s carries; a work-queue stream is read by "+
|
|
||||||
"its module's one process alone, never by another module's consumes (novox/hq ADR 0436 "+
|
|
||||||
"§6)", module, c, emitter, s.BusName()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamNameProblems are the rules about stream names no single manifest can be judged against: two
|
|
||||||
// modules declaring one name.
|
|
||||||
func StreamNameProblems(shelf Shelf) []string {
|
|
||||||
var problems []string
|
|
||||||
declaredBy := map[string]string{}
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
for _, s := range shelf[module].Streams {
|
|
||||||
name := s.BusName()
|
|
||||||
if first, taken := declaredBy[name]; taken && first != module {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)",
|
|
||||||
module, name, first))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
declaredBy[name] = module
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestrictedHearingProblems refuses a module that would hear a restricted stream without an account of its own
|
|
||||||
// (novox/hq ADR 0376 §2): a `consumes` naming a module literally and asking for events a restricted stream of
|
|
||||||
// that module carries, by a manifest that declares no `runs-as`. A module the machine's tool runner carries
|
|
||||||
// reaches the bus as the tool runner's account, which every agent session there shares, so the controller would
|
|
||||||
// compose nothing for it. Judged only where the provider is on the shelf, as a consumer may be installed before
|
|
||||||
// its emitter; a wildcard emitter never reaches a module's stream (ADR 0363 §5) and is not judged.
|
|
||||||
func RestrictedHearingProblems(shelf Shelf) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
m := shelf[module]
|
|
||||||
if m.RunsAs != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, c := range m.Consumes {
|
|
||||||
emitter, rest, named := strings.Cut(c, ".")
|
|
||||||
provider, known := shelf[emitter]
|
|
||||||
if !named || emitter == OneName || emitter == TheRest || !known {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hearing, _ := provider.StreamsHearing(rest)
|
|
||||||
for _, s := range hearing {
|
|
||||||
if s.Grant == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s consumes %s, which %s's restricted stream %s carries, so %s must run as an account of its "+
|
|
||||||
"own (runs-as): the machine's runtime runs as the operator's account, which every agent "+
|
|
||||||
"runs as, and never hears a restricted stream (novox/hq ADR 0376)",
|
|
||||||
module, c, emitter, s.BusName(), module))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// GrantedKeys reads a restricting config value (novox/hq ADR 0363 §4): each key whose list names the
|
|
||||||
// module wherever it runs, or the module on this machine as `<machine>.<module>`. A value of another shape
|
|
||||||
// grants nothing; a key that is not one usable name grants nothing.
|
|
||||||
func GrantedKeys(value any, module, machine string) []string {
|
|
||||||
if text, isText := value.(string); isText {
|
|
||||||
// A layer may hold the object as its JSON text.
|
|
||||||
var parsed any
|
|
||||||
if json.Unmarshal([]byte(text), &parsed) != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
value = parsed
|
|
||||||
}
|
|
||||||
entries, ok := value.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for key, listed := range entries {
|
|
||||||
if !eventName.MatchString(key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
names, ok := listed.([]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
s, _ := n.(string)
|
|
||||||
if s == module || (machine != "" && s == machine+"."+module) {
|
|
||||||
out = append(out, key)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Granted is a grant's pattern with its key filled.
|
|
||||||
func (g StreamGrant) Granted(key string) string { return strings.Replace(g.Events, GrantKey, key, 1) }
|
|
||||||
|
|
||||||
// narrow is the events both patterns name, as one pattern; false when they name none in common.
|
|
||||||
func narrow(a, b string) (string, bool) {
|
|
||||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
|
||||||
var out []string
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
switch {
|
|
||||||
case i == len(x) && i == len(y):
|
|
||||||
return strings.Join(out, "."), true
|
|
||||||
case i == len(x) || i == len(y):
|
|
||||||
return "", false
|
|
||||||
case x[i] == TheRest:
|
|
||||||
return strings.Join(append(out, y[i:]...), "."), true
|
|
||||||
case y[i] == TheRest:
|
|
||||||
return strings.Join(append(out, x[i:]...), "."), true
|
|
||||||
case x[i] == OneName:
|
|
||||||
out = append(out, y[i])
|
|
||||||
case y[i] == OneName || x[i] == y[i]:
|
|
||||||
out = append(out, x[i])
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeardPart is what a consumer hears of one module's stream: the patterns, local, it is granted.
|
|
||||||
type HeardPart struct {
|
|
||||||
Emitter string
|
|
||||||
Stream StreamDeclaration
|
|
||||||
Patterns []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hearing is what a consumer's `consumes` hears of the module streams the shelf declares, on one machine
|
|
||||||
// (novox/hq ADR 0363 §5): for each stream, its `consumes` narrowed to the stream's events and, for a
|
|
||||||
// restricted stream, to the parts the provider's trusted config value grants it. values answers a restricting config
|
|
||||||
// value on each machine running the provider. Answered too: whether any `consumes` names a module owning a
|
|
||||||
// stream, granted or not, and the `consumes` left for EVENTS — those naming something the provider emits
|
|
||||||
// there.
|
|
||||||
//
|
|
||||||
// Only an entry naming its emitter literally reaches a module's stream: a wildcard emitter, `**` among them,
|
|
||||||
// is EVENTS' alone, so nothing that hears every event hears a restricted stream by saying so.
|
|
||||||
func Hearing(consumer Manifest, shelf map[string]Manifest, machine string,
|
|
||||||
values func(provider, setting string) []any) (parts []HeardPart, touches bool, onEvents []string) {
|
|
||||||
byStream := map[string]*HeardPart{}
|
|
||||||
var order []string
|
|
||||||
for _, c := range consumer.Consumes {
|
|
||||||
emitter, rest, named := strings.Cut(c, ".")
|
|
||||||
provider, known := shelf[emitter]
|
|
||||||
if !named || emitter == OneName || emitter == TheRest || !known || len(provider.Streams) == 0 {
|
|
||||||
onEvents = append(onEvents, c)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hearing, whole := provider.StreamsHearing(rest)
|
|
||||||
if len(hearing) > 0 {
|
|
||||||
touches = true
|
|
||||||
}
|
|
||||||
// Kept for EVENTS while the provider says something there it names: a stream carrying it whole, or
|
|
||||||
// a provider that says nothing of it on EVENTS, leaves it nothing to hear there.
|
|
||||||
if !whole && slices.ContainsFunc(provider.EmitsAll(), func(e string) bool { return patternsOverlap(rest, e) }) {
|
|
||||||
onEvents = append(onEvents, c)
|
|
||||||
}
|
|
||||||
for _, s := range hearing {
|
|
||||||
var granted []string
|
|
||||||
if s.Grant != nil {
|
|
||||||
for _, v := range values(emitter, s.Grant.Config) {
|
|
||||||
for _, key := range GrantedKeys(v, consumer.Module, machine) {
|
|
||||||
granted = append(granted, s.Grant.Granted(key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var got []string
|
|
||||||
for _, p := range s.Events {
|
|
||||||
x, ok := narrow(rest, p)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s.Grant == nil {
|
|
||||||
got = append(got, x)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, g := range granted {
|
|
||||||
if y, ok := narrow(x, g); ok {
|
|
||||||
got = append(got, y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(got) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := emitter + "/" + s.BusName()
|
|
||||||
if byStream[id] == nil {
|
|
||||||
byStream[id] = &HeardPart{Emitter: emitter, Stream: s}
|
|
||||||
order = append(order, id)
|
|
||||||
}
|
|
||||||
byStream[id].Patterns = append(byStream[id].Patterns, got...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range order {
|
|
||||||
h := byStream[id]
|
|
||||||
h.Patterns = uncovered(h.Patterns)
|
|
||||||
parts = append(parts, *h)
|
|
||||||
}
|
|
||||||
return parts, touches, onEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
// uncovered is a list of patterns without those another names whole, and without repeats: the bus refuses
|
|
||||||
// a consumer whose filters overlap.
|
|
||||||
func uncovered(patterns []string) []string {
|
|
||||||
var out []string
|
|
||||||
for i, p := range patterns {
|
|
||||||
covered := false
|
|
||||||
for j, q := range patterns {
|
|
||||||
if i == j {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if within(p, q) && (!within(q, p) || j < i) {
|
|
||||||
covered = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !covered {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1,
|
|
||||||
// issue 654).
|
|
||||||
//
|
|
||||||
// **Two forms, one composition.** A file resource says its text inline (`content`) or names a template file
|
|
||||||
// beside its manifest (`"template": "files/<name>"`), never both. The module's build packs `files/` into an
|
|
||||||
// archive artifact of its own — an `archive` artifact built `from: "files"` — and resolving the build reads each
|
|
||||||
// template's text back from that packed archive into `content` and drops `template`. So the manifest the mesh
|
|
||||||
// holds, composition and the node-engine are exactly as they were: a module with its text in `files/` composes
|
|
||||||
// byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate,
|
|
||||||
// `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom).
|
|
||||||
//
|
|
||||||
// **files/ unless the manifest names another directory** (issue 731). A module whose files/ is already an archive
|
|
||||||
// unpacked onto the machine names its own (`"templates": "templates"`), packed into an archive no resource names, so
|
|
||||||
// its templates neither land beside what it unpacks nor change that archive's digest.
|
|
||||||
//
|
|
||||||
// **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very
|
|
||||||
// bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller
|
|
||||||
// registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a
|
|
||||||
// manifest that still names a template when it would be held — built by a builder that predates templates — is
|
|
||||||
// refused at registration (HeldTemplateProblem).
|
|
||||||
|
|
||||||
// TemplateField is the key a file resource names its template file by.
|
|
||||||
const TemplateField = "template"
|
|
||||||
|
|
||||||
// DefaultTemplatesDir is the directory a module's template files are kept in, and its templates archive is built
|
|
||||||
// from, when its manifest names none (Manifest.Templates, issue 731).
|
|
||||||
const DefaultTemplatesDir = "files"
|
|
||||||
|
|
||||||
// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive is
|
|
||||||
// built from: the one its manifest names, else files/.
|
|
||||||
func (m Manifest) TemplatesDir() string {
|
|
||||||
if m.Templates != "" {
|
|
||||||
return m.Templates
|
|
||||||
}
|
|
||||||
return DefaultTemplatesDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// templatesDirProblem is what is wrong with the templates directory a manifest names, or "": one directory of the
|
|
||||||
// module's own, a plain relative path inside it — never absolute, never leaving it, never the module's directory
|
|
||||||
// itself — and named only by a module that names a template, since a word read by nothing is refused.
|
|
||||||
func templatesDirProblem(m Manifest, templated bool) string {
|
|
||||||
d := m.Templates
|
|
||||||
if d == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(d, "/") || strings.Contains(d, "\\") || path.Clean(d) != d || d == "." || d == ".." ||
|
|
||||||
strings.HasPrefix(d, "../") {
|
|
||||||
return fmt.Sprintf("%s names the templates directory %q, which is not a directory of its own: a plain "+
|
|
||||||
"relative path inside the module's directory, such as \"templates\" (issue 731)", m.Module, d)
|
|
||||||
}
|
|
||||||
if !templated {
|
|
||||||
return fmt.Sprintf("%s names the templates directory %q and names no template: a file resource names its "+
|
|
||||||
"template as %q, or the manifest says no templates directory", m.Module, d, d+"/<name>")
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Templated is whether any of the manifest's resources, or any item it contributes (collections.go), names a
|
|
||||||
// template.
|
|
||||||
func (m Manifest) Templated() bool {
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, has := r[TemplateField]; has {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, it := range m.Items {
|
|
||||||
if it.Template != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from its
|
|
||||||
// templates directory (TemplatesDir) — or "" when it declares none, or more than one (both refused by templateProblems
|
|
||||||
// for a module naming a template).
|
|
||||||
func TemplatesArtifact(m Manifest) string {
|
|
||||||
found := templatesArtifacts(m)
|
|
||||||
if len(found) != 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return found[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func templatesArtifacts(m Manifest) []string {
|
|
||||||
if m.Build == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var found []string
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == m.TemplatesDir() {
|
|
||||||
found = append(found, a.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanFrom is an artifact's directory as one relative path: "./files/" is "files".
|
|
||||||
func cleanFrom(from string) string {
|
|
||||||
c := path.Clean(strings.TrimSpace(from))
|
|
||||||
return strings.TrimPrefix(c, "./")
|
|
||||||
}
|
|
||||||
|
|
||||||
// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing):
|
|
||||||
// dir is the module's templates directory.
|
|
||||||
func templateOf(module, dir string, r map[string]any) (string, bool, string) {
|
|
||||||
raw, has := r[TemplateField]
|
|
||||||
if !has {
|
|
||||||
return "", false, ""
|
|
||||||
}
|
|
||||||
id := fmt.Sprint(r["id"])
|
|
||||||
if t := fmt.Sprint(r["type"]); t != "file" {
|
|
||||||
return "", true, fmt.Sprintf("%s: %s is a %s and names a template; only a file takes its text from one "+
|
|
||||||
"(novox/hq ADR 0349 §2)", module, id, t)
|
|
||||||
}
|
|
||||||
name, ok := raw.(string)
|
|
||||||
if !ok || strings.TrimSpace(name) == "" {
|
|
||||||
return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q",
|
|
||||||
module, id, raw, dir+"/<name>")
|
|
||||||
}
|
|
||||||
if _, both := r["content"]; both {
|
|
||||||
return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+
|
|
||||||
"in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name)
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
|
||||||
strings.Contains(name, "\\") {
|
|
||||||
return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+
|
|
||||||
"repository: a template is %q, a plain relative path, and the build packs only %s/",
|
|
||||||
module, id, name, dir, dir+"/<name>", dir)
|
|
||||||
}
|
|
||||||
return name, true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file,
|
|
||||||
// beside content, or outside the module's templates directory; a templates directory that is not one, or that no
|
|
||||||
// template is in; or a module naming templates whose build packs no archive from that directory. Whether each
|
|
||||||
// template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve).
|
|
||||||
func templateProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
templated := m.Templated()
|
|
||||||
if wrong := templatesDirProblem(m, templated); wrong != "" {
|
|
||||||
return append(problems, wrong)
|
|
||||||
}
|
|
||||||
dir := m.TemplatesDir()
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, _, wrong := templateOf(m.Module, dir, r); wrong != "" {
|
|
||||||
problems = append(problems, wrong)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !templated {
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
switch found := templatesArtifacts(m); len(found) {
|
|
||||||
case 0:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+
|
|
||||||
"declare an artifact {\"name\": %q, \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)",
|
|
||||||
m.Module, dir, path.Base(dir), dir))
|
|
||||||
case 1:
|
|
||||||
default:
|
|
||||||
sort.Strings(found)
|
|
||||||
problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive",
|
|
||||||
m.Module, dir, strings.Join(found, " and ")))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillTemplates is resources with each template's text as its content and the template key dropped: text says the text
|
|
||||||
// of a template by its path ("<templates directory>/<name>"), and whether it has one. A template it has no text for, or
|
|
||||||
// text that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were.
|
|
||||||
func fillTemplates(module, dir string, resources []map[string]any, text func(string) (string, bool),
|
|
||||||
where string) ([]map[string]any, error) {
|
|
||||||
out := make([]map[string]any, 0, len(resources))
|
|
||||||
var problems []string
|
|
||||||
for _, r := range resources {
|
|
||||||
name, has, wrong := templateOf(module, dir, r)
|
|
||||||
if !has {
|
|
||||||
out = append(out, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if wrong != "" {
|
|
||||||
problems = append(problems, wrong)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, ok := text(name)
|
|
||||||
if !ok {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and %s holds no such file",
|
|
||||||
module, r["id"], name, where))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !utf8.ValidString(body) {
|
|
||||||
// A manifest is JSON: text that is not UTF-8 would be changed on the way, silently.
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not UTF-8 text; a "+
|
|
||||||
"file's text is carried as JSON and would not reach the machine as written", module, r["id"], name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled := make(map[string]any, len(r))
|
|
||||||
for k, v := range r {
|
|
||||||
filled[k] = v
|
|
||||||
}
|
|
||||||
delete(filled, TemplateField)
|
|
||||||
filled["content"] = body
|
|
||||||
out = append(out, filled)
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return nil, errors.New(strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillItemTemplates is items with each template's text as its content and the template dropped, as fillTemplates
|
|
||||||
// does for files (collections.go).
|
|
||||||
func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool), where string) ([]Item, error) {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
out := make([]Item, 0, len(items))
|
|
||||||
var problems []string
|
|
||||||
for _, it := range items {
|
|
||||||
if it.Template == "" {
|
|
||||||
out = append(out, it)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's item of %s: %s", m.Module, it.Collection, wrong))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, ok := text(it.Template)
|
|
||||||
switch {
|
|
||||||
case !ok:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, and %s holds no "+
|
|
||||||
"such file", m.Module, it.Collection, it.Template, where))
|
|
||||||
continue
|
|
||||||
case !utf8.ValidString(body):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: its item of %s names the template %q, which is not UTF-8 "+
|
|
||||||
"text", m.Module, it.Collection, it.Template))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
it.Template, it.Content = "", body
|
|
||||||
out = append(out, it)
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return nil, errors.New(strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree,
|
|
||||||
// by template path. Refused by name: a template the directory lacks, and anything under its templates directory
|
|
||||||
// (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a
|
|
||||||
// symlink or not a plain file — so nothing judged here can be read from outside the tree, and no tree passes here that
|
|
||||||
// the build would refuse. Each template is read through an os.Root of the module's directory besides, which no path
|
|
||||||
// element can leave.
|
|
||||||
func ReadTemplates(m Manifest, dir string) (map[string]string, error) {
|
|
||||||
problems := templatesTreeProblems(m.Module, dir, m.TemplatesDir())
|
|
||||||
root, err := os.OpenRoot(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: the module's directory cannot be opened to read its templates: %w", m.Module, err)
|
|
||||||
}
|
|
||||||
defer root.Close()
|
|
||||||
texts := map[string]string{}
|
|
||||||
type named struct {
|
|
||||||
id any
|
|
||||||
template string
|
|
||||||
wrong string
|
|
||||||
}
|
|
||||||
var templates []named
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r)
|
|
||||||
if has {
|
|
||||||
templates = append(templates, named{r["id"], name, wrong})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, it := range m.Items {
|
|
||||||
if it.Template != "" {
|
|
||||||
templates = append(templates, named{"its item of " + it.key(), it.Template,
|
|
||||||
itemTemplateProblem(m, it.Template)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, t := range templates {
|
|
||||||
r := map[string]any{"id": t.id}
|
|
||||||
name, wrong := t.template, t.wrong
|
|
||||||
if wrong != "" {
|
|
||||||
problems = append(problems, wrong)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, read := texts[name]; read {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := root.Lstat(name)
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and the module's directory "+
|
|
||||||
"has no such file", m.Module, r["id"], name))
|
|
||||||
continue
|
|
||||||
case err != nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v",
|
|
||||||
m.Module, r["id"], name, err))
|
|
||||||
continue
|
|
||||||
case !info.Mode().IsRegular():
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not a plain file; "+
|
|
||||||
"the build packs only files", m.Module, r["id"], name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, err := root.ReadFile(name)
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v",
|
|
||||||
m.Module, r["id"], name, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
texts[name] = string(body)
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return nil, errors.New(strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return texts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// templatesTreeProblems is everything under the module's templates directory (templates, inside dir) that the
|
|
||||||
// build's packing refuses: that directory itself not a plain directory, and any entry under it that is neither a
|
|
||||||
// directory nor a plain file — a symlink, to a file or a directory, a device, a pipe. Each said by its path. A
|
|
||||||
// templates directory that does not exist is no problem here: each template it would have held is refused as missing.
|
|
||||||
func templatesTreeProblems(module, dir, templates string) []string {
|
|
||||||
top := filepath.Join(dir, filepath.FromSlash(templates))
|
|
||||||
info, err := os.Lstat(top)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err)}
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return []string{fmt.Sprintf("%s: %s/ is %s, not a directory of the module's own; its templates are read "+
|
|
||||||
"only from inside the repository, and the build packs only directories and files", module, templates,
|
|
||||||
kindOf(info.Mode()))}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
err = filepath.WalkDir(top, func(at string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.IsDir() || d.Type().IsRegular() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, _ := filepath.Rel(dir, at)
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s is %s; the build packs only directories and files, so "+
|
|
||||||
"%s/ holds nothing else", module, filepath.ToSlash(rel), kindOf(d.Type()), templates))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %s/ cannot be read: %v", module, templates, err))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// kindOf says what a file that is not a plain file is.
|
|
||||||
func kindOf(mode fs.FileMode) string {
|
|
||||||
switch {
|
|
||||||
case mode&fs.ModeSymlink != 0:
|
|
||||||
return "a symlink"
|
|
||||||
case mode&fs.ModeDevice != 0:
|
|
||||||
return "a device"
|
|
||||||
case mode&fs.ModeNamedPipe != 0:
|
|
||||||
return "a named pipe"
|
|
||||||
case mode&fs.ModeSocket != 0:
|
|
||||||
return "a socket"
|
|
||||||
case mode.IsDir():
|
|
||||||
return "a directory"
|
|
||||||
}
|
|
||||||
return "not a plain file"
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTemplatesFrom is the manifest with each template's text read from the module's directory in a repository
|
|
||||||
// tree as its content: what `module check`, the merge gate and `module add` judge, where nothing was built. Read
|
|
||||||
// again strictly afterwards, so every rule about a file's text judges the template's text exactly as it judges
|
|
||||||
// inline content. A manifest naming no template is returned as it is.
|
|
||||||
func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) {
|
|
||||||
if !m.Templated() {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
texts, err := ReadTemplates(m, dir)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
filled, err := fillTemplates(m.Module, m.TemplatesDir(), m.Resources, func(name string) (string, bool) {
|
|
||||||
t, ok := texts[name]
|
|
||||||
return t, ok
|
|
||||||
}, "the module's directory")
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
items, err := fillItemTemplates(m, m.Items, func(name string) (string, bool) {
|
|
||||||
t, ok := texts[name]
|
|
||||||
return t, ok
|
|
||||||
}, "the module's directory")
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
out := m
|
|
||||||
out.Resources = filled
|
|
||||||
out.Items = items
|
|
||||||
// Nothing names a template now, so nothing names its directory.
|
|
||||||
out.Templates = ""
|
|
||||||
// A moved module's mark was judged above, while it still named its templates; read again with its text inline,
|
|
||||||
// it is judged as it composes without its build's templates archive — as before, nothing handed (handed.go).
|
|
||||||
out.Moved = false
|
|
||||||
raw, err := json.Marshal(out)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
again, err := parseManifest(raw, m.predefined)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
again.Moved = m.Moved
|
|
||||||
return again, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeldTemplateProblem is why a manifest cannot be held by the mesh because it still names a template, or "": the
|
|
||||||
// manifest the mesh holds carries every file's text, which a build reads back from its templates archive. One
|
|
||||||
// naming a template was resolved by a builder that does not read templates, and would compose files with no text.
|
|
||||||
func HeldTemplateProblem(m Manifest) string {
|
|
||||||
var named []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if t, has := r[TemplateField]; has {
|
|
||||||
named = append(named, fmt.Sprintf("%v (%v)", r["id"], t))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, it := range m.Items {
|
|
||||||
if it.Template != "" {
|
|
||||||
named = append(named, fmt.Sprintf("its item of %s (%s)", it.Collection, it.Template))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(named) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s still names the template(s) of %s: the manifest the mesh holds carries each file's "+
|
|
||||||
"text, read back from the build's templates archive — this one was resolved by a builder that does not "+
|
|
||||||
"read templates, or not built at all (novox/hq ADR 0349 §2)", m.Module, strings.Join(named, ", "))
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Which settings are the controller's terminal's alone (novox/hq issue 339).
|
|
||||||
//
|
|
||||||
// A setting is the operator's word on how a module is configured, and the `settings` verb writes it for any
|
|
||||||
// caller allowed to call verbs — agents among them. Most settings change only the module itself. Some change
|
|
||||||
// what root or another module trusts, and those are said at the terminal alone, never through a verb:
|
|
||||||
//
|
|
||||||
// 1. `places` and `accesses`: where the node-engine creates and, as root, owns a module's directories, and
|
|
||||||
// which of the machine's paths are mounted into its container.
|
|
||||||
// 2. **Every key a provider serves**, and every setting a served value asks for. A setting overrides a served
|
|
||||||
// key (Settle), and what is served is what every consumer of the provision connects to and believes: a
|
|
||||||
// database's port, an object store's scheme, a registry's port, an identity provider's issuer and token
|
|
||||||
// path. Through a verb, any caller could point every consumer at a listener of its own and collect the
|
|
||||||
// credentials they present.
|
|
||||||
// 3. **Every setting a file asks for, unless the file says `"trusted": false`.** A file root or a consumer trusts —
|
|
||||||
// a logind drop-in, an env file that says which uid a container runs as, a script run as root — must not
|
|
||||||
// change through a verb, and the safe reading of a file that says nothing is that it is one of them (fail
|
|
||||||
// closed). `"trusted": false` is the opt-out, for a file nothing trusts: a person's own notifier settings.
|
|
||||||
// `module check` lists the files that say nothing, so an author can opt one out where that is true.
|
|
||||||
// **A mergeable file asks for every key its own content names** (novox/hq issue 340): a setting of that key
|
|
||||||
// lands in the file as a `${setting:…}` would, without the file ever spelling one. The agent's module keeps the
|
|
||||||
// managed settings and tool servers every Claude Code session on a node obeys in one, and through a verb an
|
|
||||||
// agent could have given every person's session a hook of its own.
|
|
||||||
//
|
|
||||||
// 4. **A module's whole layer, when it has a mergeable file not marked `"trusted": false`** (novox/hq issue 340,
|
|
||||||
// TrustedMergeable). A mergeable file takes any key a layer sets, not only those its content names, so no list
|
|
||||||
// of keys covers it: an empty runtime configuration a provider reads would take a `url` of the caller's.
|
|
||||||
// **Unless the module's files name the keys they take** (novox/hq ADR 0326, ADR 0329 §5): then a mergeable
|
|
||||||
// file takes exactly its `takes-settings` and no other key, so those keys of a trusted file are terminal keys
|
|
||||||
// and the rest of the layer is not. A key no file names is stray and refused where it is set. So betterbird's
|
|
||||||
// `tags`, in a file nothing trusts, is set through the verb, while its `accounts` and `identities` are not.
|
|
||||||
// 5. **Every setting a directory's owner asks for** (novox/hq issue 279): root chowns the directory to the number
|
|
||||||
// at the next push, so through a verb any caller could hand itself — or uid 0 — a module's folder. Whatever
|
|
||||||
// else asks for the key, a file marked `"trusted": false` included, the owner makes it the terminal's.
|
|
||||||
//
|
|
||||||
// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered.
|
|
||||||
|
|
||||||
// TrustedField is the key a file resource carries to say whether the settings it asks for are trusted: true, or
|
|
||||||
// absent, makes each a terminal key; false says, out loud, that none changes what root or a consumer trusts. Said in the
|
|
||||||
// catalogue, never on the machine: the composer takes it out before the node-engine, which parses strictly.
|
|
||||||
const TrustedField = "trusted"
|
|
||||||
|
|
||||||
// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone: places and
|
|
||||||
// accesses, every key its provisions serve and every setting a served value asks for, every setting a file
|
|
||||||
// asks for unless it says `"trusted": false`, and every setting a directory's owner asks for. Places and accesses first, then the rest sorted.
|
|
||||||
func TerminalKeys(m Manifest) []string {
|
|
||||||
keys := map[string]bool{}
|
|
||||||
for _, served := range m.Serves {
|
|
||||||
for key, value := range served {
|
|
||||||
keys[key] = true
|
|
||||||
if s, ok := value.(string); ok {
|
|
||||||
for _, asked := range settingsUsed(s) {
|
|
||||||
keys[asked] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
owner, _ := r["owner"].(string)
|
|
||||||
for _, asked := range settingsUsed(owner) {
|
|
||||||
keys[asked] = true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if trusted, said := r[TrustedField].(bool); said && !trusted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, asked := range asksFor(r) {
|
|
||||||
keys[asked] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(keys, PlacesSetting)
|
|
||||||
delete(keys, AccessesSetting)
|
|
||||||
rest := make([]string, 0, len(keys))
|
|
||||||
for k := range keys {
|
|
||||||
rest = append(rest, k)
|
|
||||||
}
|
|
||||||
sort.Strings(rest)
|
|
||||||
return append([]string{PlacesSetting, AccessesSetting}, rest...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnsaidTrust is every file of a module that asks for a setting and does not say whether it is trusted, by id:
|
|
||||||
// each counts as trusted, and is listed so an author can opt out a file nothing trusts.
|
|
||||||
func UnsaidTrust(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(asksFor(r)) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, said := r[TrustedField]; !said {
|
|
||||||
out = append(out, fmt.Sprint(r["id"]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrustedMergeable is every mergeable file of a module not marked `"trusted": false`, by id (novox/hq issue 340).
|
|
||||||
// A mergeable file takes any key a layer sets, not only those its content names: an empty runtime configuration a
|
|
||||||
// provider reads takes a `url` of a caller's as surely as a declared one. So a module holding one has its whole
|
|
||||||
// layer set at the controller's terminal; a verb may read it and change nothing. A module whose mergeable files
|
|
||||||
// name the keys they take (ADR 0326) has none here: each such file takes only its named keys, which TerminalKeys
|
|
||||||
// holds for a trusted one (ADR 0329 §5).
|
|
||||||
func TrustedMergeable(m Manifest) []string {
|
|
||||||
if namesItsKeys(m) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if how, _ := r["merge"].(string); how == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if trusted, said := r[TrustedField].(bool); said && !trusted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprint(r["id"]))
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// asksFor is every setting a file resource asks for: each `${setting:…}` in its content and, for a mergeable file,
|
|
||||||
// every key at the top of the content it merges into (novox/hq issue 340). Those are the keys the file declares it
|
|
||||||
// takes: a layer's value for one of them lands in it as surely as a placeholder would be filled. A key the content
|
|
||||||
// does not name may land too, but nothing reading the file was written to read it.
|
|
||||||
func asksFor(r map[string]any) []string {
|
|
||||||
content, _ := r["content"].(string)
|
|
||||||
asked := settingsUsed(content)
|
|
||||||
if how, _ := r["merge"].(string); how != "" {
|
|
||||||
if _, names := takenKeys(r); names {
|
|
||||||
// The file takes exactly the keys it names (ADR 0326), whatever its content holds, and a renamed
|
|
||||||
// one under the file's own spelling too, which a layer said before its module moved (issue 751).
|
|
||||||
asked = append(asked, takenNames(r)...)
|
|
||||||
sort.Strings(asked)
|
|
||||||
return asked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if how, _ := r["merge"].(string); how != "" && strings.TrimSpace(content) != "" {
|
|
||||||
var base map[string]any
|
|
||||||
if json.Unmarshal([]byte(content), &base) == nil {
|
|
||||||
for key := range base {
|
|
||||||
asked = append(asked, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(asked)
|
|
||||||
return asked
|
|
||||||
}
|
|
||||||
|
|
||||||
// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but
|
|
||||||
// a file. Refused at registration and by `module check`.
|
|
||||||
func TrustProblems(m Manifest) []string {
|
|
||||||
var out []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
v, said := r[TrustedField]
|
|
||||||
if !said {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+
|
|
||||||
"asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := v.(bool); !ok {
|
|
||||||
out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)",
|
|
||||||
m.Module, r["id"], TrustedField, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[[block]]
|
|
||||||
block = "battery"
|
|
||||||
format = "$icon $percentage{ $time|}"
|
|
||||||
charging_format = "$icon $percentage{ $time|}"
|
|
||||||
not_charging_format = "$icon $percentage"
|
|
||||||
full_format = "$icon $percentage"
|
|
||||||
empty_format = "$icon $percentage"
|
|
||||||
missing_format = ""
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-value power.draw ${machine:name}"
|
|
||||||
interval = 5
|
|
||||||
hide_when_empty = true
|
|
||||||
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
|
||||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
|
||||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
|
||||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
|
||||||
# here works the same on any machine.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "tea_timer"
|
|
||||||
format = "$icon{ $minutes:$seconds|}"
|
|
||||||
done_cmd = "notify-send 'Timer finished'"
|
|
||||||
|
|
||||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
|
||||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
|
||||||
# list, in the launcher's menu.
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-updates 10"
|
|
||||||
json = true
|
|
||||||
interval = 1800
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "cpu"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/home"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "memory"
|
|
||||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
|
||||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
|
||||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
|
||||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
|
||||||
# what they show by this module's template, and only on a machine that has what they show.
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "docker"
|
|
||||||
interval = 2
|
|
||||||
format = "$icon $running/$total"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "sound"
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "pavucontrol"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
|
||||||
# power draw, where the machine has them.
|
|
||||||
# power
|
|
||||||
[[block]]
|
|
||||||
block = "battery"
|
|
||||||
format = "$icon $percentage{ $time|}"
|
|
||||||
charging_format = "$icon $percentage{ $time|}"
|
|
||||||
not_charging_format = "$icon $percentage"
|
|
||||||
full_format = "$icon $percentage"
|
|
||||||
empty_format = "$icon $percentage"
|
|
||||||
missing_format = ""
|
|
||||||
|
|
||||||
|
|
||||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
|
||||||
[[block]]
|
|
||||||
block = "weather"
|
|
||||||
format = "$icon $weather_verbose ($location) $temp"
|
|
||||||
autolocate = true
|
|
||||||
autolocate_interval = 600
|
|
||||||
[block.service]
|
|
||||||
name = "metno"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "notify"
|
|
||||||
driver = "dunst"
|
|
||||||
format = " $icon {($notification_count.eng(w:1))|}"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "time"
|
|
||||||
interval = 1
|
|
||||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
|
||||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "focused_window"
|
|
||||||
[block.format]
|
|
||||||
full = " $title.str(max_w:50) |"
|
|
||||||
short = " $title.str(max_w:100) |"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "uptime"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
|
||||||
# so that one that does is never dropped.
|
|
||||||
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
|
||||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
|
||||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
|
||||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
|
||||||
# here works the same on any machine.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "tea_timer"
|
|
||||||
format = "$icon{ $minutes:$seconds|}"
|
|
||||||
done_cmd = "notify-send 'Timer finished'"
|
|
||||||
|
|
||||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
|
||||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
|
||||||
# list, in the launcher's menu.
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-updates 10"
|
|
||||||
json = true
|
|
||||||
interval = 1800
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "cpu"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/home"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "memory"
|
|
||||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
|
||||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
|
||||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
|
||||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
|
||||||
# what they show by this module's template, and only on a machine that has what they show.
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "docker"
|
|
||||||
interval = 2
|
|
||||||
format = "$icon $running/$total"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "sound"
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "pavucontrol"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
|
||||||
# power draw, where the machine has them.
|
|
||||||
# power
|
|
||||||
[[block]]
|
|
||||||
block = "battery"
|
|
||||||
format = "$icon $percentage{ $time|}"
|
|
||||||
charging_format = "$icon $percentage{ $time|}"
|
|
||||||
not_charging_format = "$icon $percentage"
|
|
||||||
full_format = "$icon $percentage"
|
|
||||||
empty_format = "$icon $percentage"
|
|
||||||
missing_format = ""
|
|
||||||
|
|
||||||
# power
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-value power.draw workstation"
|
|
||||||
interval = 5
|
|
||||||
hide_when_empty = true
|
|
||||||
|
|
||||||
|
|
||||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
|
||||||
[[block]]
|
|
||||||
block = "weather"
|
|
||||||
format = "$icon $weather_verbose ($location) $temp"
|
|
||||||
autolocate = true
|
|
||||||
autolocate_interval = 600
|
|
||||||
[block.service]
|
|
||||||
name = "metno"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "notify"
|
|
||||||
driver = "dunst"
|
|
||||||
format = " $icon {($notification_count.eng(w:1))|}"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "time"
|
|
||||||
interval = 1
|
|
||||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
|
||||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "focused_window"
|
|
||||||
[block.format]
|
|
||||||
full = " $title.str(max_w:50) |"
|
|
||||||
short = " $title.str(max_w:100) |"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "uptime"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
|
||||||
# so that one that does is never dropped.
|
|
||||||
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
|
||||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
|
||||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
|
||||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
|
||||||
# here works the same on any machine.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "tea_timer"
|
|
||||||
format = "$icon{ $minutes:$seconds|}"
|
|
||||||
done_cmd = "notify-send 'Timer finished'"
|
|
||||||
|
|
||||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
|
||||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
|
||||||
# list, in the launcher's menu.
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-updates 10"
|
|
||||||
json = true
|
|
||||||
interval = 1800
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "cpu"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/home"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "memory"
|
|
||||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
|
||||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
|
||||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
|
||||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
|
||||||
# what they show by this module's template, and only on a machine that has what they show.
|
|
||||||
${collected:contribution:node-bar:block:bar=bottom,place=resources}
|
|
||||||
[[block]]
|
|
||||||
block = "docker"
|
|
||||||
interval = 2
|
|
||||||
format = "$icon $running/$total"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "sound"
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "pavucontrol"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
|
||||||
# power draw, where the machine has them.
|
|
||||||
${collected:contribution:node-bar:block:bar=bottom,place=status}
|
|
||||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
|
||||||
[[block]]
|
|
||||||
block = "weather"
|
|
||||||
format = "$icon $weather_verbose ($location) $temp"
|
|
||||||
autolocate = true
|
|
||||||
autolocate_interval = 600
|
|
||||||
[block.service]
|
|
||||||
name = "metno"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "notify"
|
|
||||||
driver = "dunst"
|
|
||||||
format = " $icon {($notification_count.eng(w:1))|}"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "time"
|
|
||||||
interval = 1
|
|
||||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
|
||||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
|
||||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
|
||||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
|
||||||
# here works the same on any machine.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "tea_timer"
|
|
||||||
format = "$icon{ $minutes:$seconds|}"
|
|
||||||
done_cmd = "notify-send 'Timer finished'"
|
|
||||||
|
|
||||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
|
||||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
|
||||||
# list, in the launcher's menu.
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-updates 10"
|
|
||||||
json = true
|
|
||||||
interval = 1800
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "cpu"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/home"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "memory"
|
|
||||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
|
||||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
|
||||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
|
||||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
|
||||||
# what they show by this module's template, and only on a machine that has what they show.
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "docker"
|
|
||||||
interval = 2
|
|
||||||
format = "$icon $running/$total"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "sound"
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "pavucontrol"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
|
||||||
# power draw, where the machine has them.
|
|
||||||
|
|
||||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
|
||||||
[[block]]
|
|
||||||
block = "weather"
|
|
||||||
format = "$icon $weather_verbose ($location) $temp"
|
|
||||||
autolocate = true
|
|
||||||
autolocate_interval = 600
|
|
||||||
[block.service]
|
|
||||||
name = "metno"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "notify"
|
|
||||||
driver = "dunst"
|
|
||||||
format = " $icon {($notification_count.eng(w:1))|}"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "time"
|
|
||||||
interval = 1
|
|
||||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
|
||||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "focused_window"
|
|
||||||
[block.format]
|
|
||||||
full = " $title.str(max_w:50) |"
|
|
||||||
short = " $title.str(max_w:100) |"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "uptime"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
|
||||||
# so that one that does is never dropped.
|
|
||||||
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
|
||||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
|
||||||
# person's devices left out: a block that depends on the hardware is contributed by the module that
|
|
||||||
# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written
|
|
||||||
# here works the same on any machine.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "tea_timer"
|
|
||||||
format = "$icon{ $minutes:$seconds|}"
|
|
||||||
done_cmd = "notify-send 'Timer finished'"
|
|
||||||
|
|
||||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
|
||||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
|
||||||
# list, in the launcher's menu.
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-updates 10"
|
|
||||||
json = true
|
|
||||||
interval = 1800
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "cpu"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "disk_space"
|
|
||||||
path = "/home"
|
|
||||||
info_type = "used"
|
|
||||||
alert_unit = "GB"
|
|
||||||
interval = 20
|
|
||||||
warning = 850
|
|
||||||
alert = 920
|
|
||||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "memory"
|
|
||||||
# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure
|
|
||||||
# module judges by. Counting the cache as used read near full on a machine with room to spare.
|
|
||||||
format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"
|
|
||||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from
|
|
||||||
# what they show by this module's template, and only on a machine that has what they show.
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "docker"
|
|
||||||
interval = 2
|
|
||||||
format = "$icon $running/$total"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "sound"
|
|
||||||
[[block.click]]
|
|
||||||
button = "left"
|
|
||||||
cmd = "pavucontrol"
|
|
||||||
|
|
||||||
# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and
|
|
||||||
# power draw, where the machine has them.
|
|
||||||
# power
|
|
||||||
[[block]]
|
|
||||||
block = "custom"
|
|
||||||
command = "~/.local/bin/i3status-value power.draw workstation"
|
|
||||||
interval = 5
|
|
||||||
hide_when_empty = true
|
|
||||||
|
|
||||||
|
|
||||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
|
||||||
[[block]]
|
|
||||||
block = "weather"
|
|
||||||
format = "$icon $weather_verbose ($location) $temp"
|
|
||||||
autolocate = true
|
|
||||||
autolocate_interval = 600
|
|
||||||
[block.service]
|
|
||||||
name = "metno"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "notify"
|
|
||||||
driver = "dunst"
|
|
||||||
format = " $icon {($notification_count.eng(w:1))|}"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "time"
|
|
||||||
interval = 1
|
|
||||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
|
||||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "focused_window"
|
|
||||||
[block.format]
|
|
||||||
full = " $title.str(max_w:50) |"
|
|
||||||
short = " $title.str(max_w:100) |"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "uptime"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
|
||||||
# so that one that does is never dropped.
|
|
||||||
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
|
|
||||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
|
||||||
icons_format = "{icon}"
|
|
||||||
|
|
||||||
[theme]
|
|
||||||
theme = "plain"
|
|
||||||
[theme.overrides]
|
|
||||||
idle_bg = "#000000"
|
|
||||||
idle_fg = "#f1f1f1"
|
|
||||||
info_bg = "#000000"
|
|
||||||
info_fg = "#f1f1f1"
|
|
||||||
good_bg = "#000000"
|
|
||||||
good_fg = "#859900"
|
|
||||||
warning_bg = "#000000"
|
|
||||||
warning_fg = "#de5200"
|
|
||||||
critical_bg = "#000000"
|
|
||||||
critical_fg = "#ff0000"
|
|
||||||
separator = " | "
|
|
||||||
separator_fg = "#ffffff"
|
|
||||||
separator_bg = "#000000"
|
|
||||||
|
|
||||||
[icons]
|
|
||||||
icons = "custom-icons"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "focused_window"
|
|
||||||
[block.format]
|
|
||||||
full = " $title.str(max_w:50) |"
|
|
||||||
short = " $title.str(max_w:100) |"
|
|
||||||
|
|
||||||
[[block]]
|
|
||||||
block = "uptime"
|
|
||||||
interval = 60
|
|
||||||
|
|
||||||
# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept
|
|
||||||
# so that one that does is never dropped.
|
|
||||||
${collected:contribution:node-bar:block:bar=top,place=resources}${collected:contribution:node-bar:block:bar=top,place=status}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# adwaita
|
|
||||||
export GTK2_RC_FILES='/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc'
|
|
||||||
export GTK_THEME='Adwaita:dark'
|
|
||||||
export QT_QPA_PLATFORMTHEME='qt6ct'
|
|
||||||
export QT_SELECT='6'
|
|
||||||
export QT_STYLE_OVERRIDE='Fusion'
|
|
||||||
export XCURSOR_SIZE='24'
|
|
||||||
export XCURSOR_THEME='Adwaita'
|
|
||||||
# clipmenu
|
|
||||||
export CM_HISTLENGTH='15'
|
|
||||||
export CM_MAX_CLIPS='500'
|
|
||||||
export CM_SELECTIONS='clipboard'
|
|
||||||
# i3
|
|
||||||
export XDG_CURRENT_DESKTOP='i3'
|
|
||||||
export XDG_SESSION_DESKTOP='i3'
|
|
||||||
# xterm
|
|
||||||
export TERMINAL='xterm'
|
|
||||||
# zsh
|
|
||||||
export EDITOR='vim'
|
|
||||||
export VISUAL='vim'
|
|
||||||
export XDG_CONFIG_HOME='/home/op/.config'
|
|
||||||
# zsh
|
|
||||||
case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac
|
|
||||||
case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac
|
|
||||||
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
|
||||||
export PATH
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# adwaita
|
|
||||||
GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
|
||||||
GTK_THEME=Adwaita:dark
|
|
||||||
QT_QPA_PLATFORMTHEME=qt6ct
|
|
||||||
QT_SELECT=6
|
|
||||||
QT_STYLE_OVERRIDE=Fusion
|
|
||||||
XCURSOR_SIZE=24
|
|
||||||
XCURSOR_THEME=Adwaita
|
|
||||||
# clipmenu
|
|
||||||
CM_HISTLENGTH=15
|
|
||||||
CM_MAX_CLIPS=500
|
|
||||||
CM_SELECTIONS=clipboard
|
|
||||||
# i3
|
|
||||||
XDG_CURRENT_DESKTOP=i3
|
|
||||||
XDG_SESSION_DESKTOP=i3
|
|
||||||
# xterm
|
|
||||||
TERMINAL=xterm
|
|
||||||
# zsh
|
|
||||||
EDITOR=vim
|
|
||||||
VISUAL=vim
|
|
||||||
XDG_CONFIG_HOME=/home/op/.config
|
|
||||||
# zsh
|
|
||||||
PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# zsh
|
|
||||||
export EDITOR='vim'
|
|
||||||
export VISUAL='vim'
|
|
||||||
export XDG_CONFIG_HOME='/home/op/.config'
|
|
||||||
# zsh
|
|
||||||
case ":${PATH}:" in *':/home/op/scripts/bin:'*) ;; *) PATH='/home/op/scripts/bin'"${PATH:+:${PATH}}" ;; esac
|
|
||||||
case ":${PATH}:" in *':/home/op/scripts:'*) ;; *) PATH='/home/op/scripts'"${PATH:+:${PATH}}" ;; esac
|
|
||||||
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
|
|
||||||
export PATH
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# zsh
|
|
||||||
EDITOR=vim
|
|
||||||
VISUAL=vim
|
|
||||||
XDG_CONFIG_HOME=/home/op/.config
|
|
||||||
# zsh
|
|
||||||
PATH=/home/op/.local/bin:/home/op/scripts:/home/op/scripts/bin${PATH:+:$PATH}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"grafana.bound-influxdb-api": {
|
|
||||||
"content": "{\n \"as\": \"mesh_workstation_grafana\",\n \"at\": \"workstation.internal\",\n \"binding\": 1,\n \"from\": \"workstation\",\n \"generated\": \"by the mesh — do not edit; replaced whenever this changes. The credential is not here: it is sealed, in the file this module's manifest names under `secrets`\",\n \"provision\": \"influxdb-api\",\n \"serves\": {\n \"bucket\": \"made-up-bucket\",\n \"org\": \"made-up-org\",\n \"port\": 8086,\n \"scheme\": \"http\"\n }\n}\n",
|
|
||||||
"id": "grafana.bound-influxdb-api",
|
|
||||||
"mode": "0644",
|
|
||||||
"path": "/var/lib/grafana/influxdb.json",
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
"grafana.influxdb-datasource": {
|
|
||||||
"content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: http://workstation.internal:8086\n user: mesh_workstation_grafana\n isDefault: false\n editable: false\n jsonData:\n dbName: made-up-bucket\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n",
|
|
||||||
"id": "grafana.influxdb-datasource",
|
|
||||||
"mode": "0644",
|
|
||||||
"path": "/var/lib/grafana/influxdb-datasources/mesh-influxdb.yaml",
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
"grafana.influxdb-datasources": {
|
|
||||||
"id": "grafana.influxdb-datasources",
|
|
||||||
"mode": "0755",
|
|
||||||
"owner": "472:0",
|
|
||||||
"path": "/var/lib/grafana/influxdb-datasources",
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
"grafana.mesh-state": {
|
|
||||||
"id": "grafana.mesh-state",
|
|
||||||
"mode": "0700",
|
|
||||||
"path": "/var/lib/mesh/grafana",
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
"grafana.state": {
|
|
||||||
"id": "grafana.state",
|
|
||||||
"mode": "0700",
|
|
||||||
"path": "/var/lib/grafana",
|
|
||||||
"type": "directory"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"grafana.bound-influxdb-api": {
|
|
||||||
"content": "{\n \"as\": \"mesh_workstation_grafana\",\n \"at\": \"workstation.internal\",\n \"binding\": 1,\n \"from\": \"workstation\",\n \"generated\": \"by the mesh — do not edit; replaced whenever this changes. The credential is not here: it is sealed, in the file this module's manifest names under `secrets`\",\n \"provision\": \"influxdb-api\",\n \"serves\": {\n \"bucket\": \"default\",\n \"org\": \"mesh\",\n \"port\": 8086,\n \"scheme\": \"http\"\n }\n}\n",
|
|
||||||
"id": "grafana.bound-influxdb-api",
|
|
||||||
"mode": "0644",
|
|
||||||
"path": "/var/lib/grafana/influxdb.json",
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
"grafana.influxdb-datasource": {
|
|
||||||
"content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: http://workstation.internal:8086\n user: mesh_workstation_grafana\n isDefault: false\n editable: false\n jsonData:\n dbName: default\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n",
|
|
||||||
"id": "grafana.influxdb-datasource",
|
|
||||||
"mode": "0644",
|
|
||||||
"path": "/var/lib/grafana/influxdb-datasources/mesh-influxdb.yaml",
|
|
||||||
"type": "file"
|
|
||||||
},
|
|
||||||
"grafana.influxdb-datasources": {
|
|
||||||
"id": "grafana.influxdb-datasources",
|
|
||||||
"mode": "0755",
|
|
||||||
"owner": "472:0",
|
|
||||||
"path": "/var/lib/grafana/influxdb-datasources",
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
"grafana.mesh-state": {
|
|
||||||
"id": "grafana.mesh-state",
|
|
||||||
"mode": "0700",
|
|
||||||
"path": "/var/lib/mesh/grafana",
|
|
||||||
"type": "directory"
|
|
||||||
},
|
|
||||||
"grafana.state": {
|
|
||||||
"id": "grafana.state",
|
|
||||||
"mode": "0700",
|
|
||||||
"path": "/var/lib/grafana",
|
|
||||||
"type": "directory"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-10
@@ -1,10 +0,0 @@
|
|||||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
|
||||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
|
||||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
|
||||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
|
||||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
|
||||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
|
||||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
|
||||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
|
||||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
|
||||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
|
||||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
|
||||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
|
||||||
sleep 2
|
|
||||||
owner=$(ps -o user= -C i3 | head -n 1)
|
|
||||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
true
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
|
|
||||||
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
|
|
||||||
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
|
|
||||||
# the real claude: exec, sudo and its sh see no alias.
|
|
||||||
if [[ -r /etc/claude-code/agent-account ]]; then
|
|
||||||
() {
|
|
||||||
local agent=$(</etc/claude-code/agent-account)
|
|
||||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
|
||||||
alias claude=claude-agent
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.
|
|
||||||
# A script, sudo and a document meant to work everywhere say mesh-cli.
|
|
||||||
alias nox=mesh-cli
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.
|
|
||||||
[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme
|
|
||||||
[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
# Suggestions from history as you type, from the distribution's package.
|
|
||||||
[[ ! -f /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
# Syntax highlighting of the command line, from the distribution's package. Last, as upstream asks:
|
|
||||||
# it wraps every widget defined before it.
|
|
||||||
[[ ! -f /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,
|
|
||||||
# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a
|
|
||||||
# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.
|
|
||||||
${shell:after-wake:first}${shell:after-wake:normal}${shell:after-wake:last}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# The machine's hotkeys, written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on
|
|
||||||
# every push: a key is added by the module it belongs to, as a contribution to node-hotkeys.
|
|
||||||
# A line is: the key, its state (1 pressed, 2 held, 0 released), the command.
|
|
||||||
${contribution:node-hotkeys:trigger}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code
|
|
||||||
# other modules contribute in three slots. It is replaced at every push. Everything below it is
|
|
||||||
# yours, kept as you wrote it, and runs after it, so your lines win.
|
|
||||||
${shell:zsh:first}
|
|
||||||
# History, kept across sessions and shared between open shells. Without these zsh saves none:
|
|
||||||
# SAVEHIST defaults to 0 (novox/hq to-be 41, found on review 2026-10-04).
|
|
||||||
HISTFILE="$HOME/.zsh_history"
|
|
||||||
HISTSIZE=100000
|
|
||||||
SAVEHIST=100000
|
|
||||||
setopt EXTENDED_HISTORY SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_REDUCE_BLANKS
|
|
||||||
|
|
||||||
# The completion system. Nothing initialised it, so only zsh's basic completion ran. Its cache
|
|
||||||
# goes under ~/.cache rather than beside ~/.zshrc.
|
|
||||||
[[ -d "$HOME/.cache/zsh" ]] || mkdir -p "$HOME/.cache/zsh"
|
|
||||||
autoload -Uz compinit && compinit -d "$HOME/.cache/zsh/zcompdump"
|
|
||||||
zstyle ':completion:*' menu select
|
|
||||||
|
|
||||||
# Terminal title: host, directory, git branch
|
|
||||||
function set_terminal_title() {
|
|
||||||
local git_branch=""
|
|
||||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
|
||||||
git_branch=" ($(git branch --show-current 2>/dev/null))"
|
|
||||||
fi
|
|
||||||
print -Pn "\e]2;%m: %~${git_branch}\a"
|
|
||||||
}
|
|
||||||
precmd_functions+=(set_terminal_title)
|
|
||||||
|
|
||||||
# Keybindings
|
|
||||||
bindkey "^[[H" beginning-of-line # Home
|
|
||||||
bindkey "^[OH" beginning-of-line # Home
|
|
||||||
bindkey "^A" beginning-of-line # Ctrl + A
|
|
||||||
bindkey "^[[F" end-of-line # End
|
|
||||||
bindkey "^[OF" end-of-line # End
|
|
||||||
bindkey "^E" end-of-line # Ctrl + E
|
|
||||||
bindkey "^[[3~" delete-char # Del
|
|
||||||
|
|
||||||
# Colour support of ls and friends
|
|
||||||
if [ -x /usr/bin/dircolors ]; then
|
|
||||||
test -r $HOME/.dircolors && eval "$(dircolors -b $HOME/.dircolors)" || eval "$(dircolors -b)"
|
|
||||||
alias ls='ls --color=auto'
|
|
||||||
alias dir='dir --color=auto'
|
|
||||||
alias vdir='vdir --color=auto'
|
|
||||||
alias grep='grep --color=auto'
|
|
||||||
alias fgrep='fgrep --color=auto'
|
|
||||||
alias egrep='egrep --color=auto'
|
|
||||||
fi
|
|
||||||
alias ll='ls -alhF'
|
|
||||||
alias la='ls -Ah'
|
|
||||||
alias l='ls -CFh'
|
|
||||||
|
|
||||||
# A throwaway container, and what takes the space under a directory
|
|
||||||
alias drun='docker run -it --rm'
|
|
||||||
disksize() {
|
|
||||||
du -h --max-depth=1 "${1:-.}" | sort -h
|
|
||||||
}
|
|
||||||
sudo-disksize() {
|
|
||||||
sudo du -h --max-depth=1 "${1:-.}" | sort -h
|
|
||||||
}
|
|
||||||
|
|
||||||
${shell:zsh:normal}${shell:zsh:last}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
# What every module on this machine runs at the moment after-wake, as the mesh placed it (module power,
|
|
||||||
# novox/hq ADR 0211). Replaced on every push: code is added by the module it belongs to, as a
|
|
||||||
# contribution for this moment. Each module's piece runs on its own, as root, bounded in time.
|
|
||||||
# asus-zephyrus-g14
|
|
||||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
|
||||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
|
||||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
|
||||||
sleep 2
|
|
||||||
owner=$(ps -o user= -C i3 | head -n 1)
|
|
||||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
true
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# The machine's hotkeys, written by the mesh (module triggerhappy, novox/hq ADR 0212). Replaced on
|
|
||||||
# every push: a key is added by the module it belongs to, as a contribution to node-hotkeys.
|
|
||||||
# A line is: the key, its state (1 pressed, 2 held, 0 released), the command.
|
|
||||||
# asus-zephyrus-g14
|
|
||||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
|
||||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
|
||||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
|
||||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
|
||||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
|
||||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
|
||||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
|
||||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
|
||||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
|
||||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
-88
@@ -1,88 +0,0 @@
|
|||||||
# The mesh's block (module zsh, novox/hq ADR 0204): the defaults every machine shares, and the code
|
|
||||||
# other modules contribute in three slots. It is replaced at every push. Everything below it is
|
|
||||||
# yours, kept as you wrote it, and runs after it, so your lines win.
|
|
||||||
|
|
||||||
# History, kept across sessions and shared between open shells. Without these zsh saves none:
|
|
||||||
# SAVEHIST defaults to 0 (novox/hq to-be 41, found on review 2026-10-04).
|
|
||||||
HISTFILE="$HOME/.zsh_history"
|
|
||||||
HISTSIZE=100000
|
|
||||||
SAVEHIST=100000
|
|
||||||
setopt EXTENDED_HISTORY SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_REDUCE_BLANKS
|
|
||||||
|
|
||||||
# The completion system. Nothing initialised it, so only zsh's basic completion ran. Its cache
|
|
||||||
# goes under ~/.cache rather than beside ~/.zshrc.
|
|
||||||
[[ -d "$HOME/.cache/zsh" ]] || mkdir -p "$HOME/.cache/zsh"
|
|
||||||
autoload -Uz compinit && compinit -d "$HOME/.cache/zsh/zcompdump"
|
|
||||||
zstyle ':completion:*' menu select
|
|
||||||
|
|
||||||
# Terminal title: host, directory, git branch
|
|
||||||
function set_terminal_title() {
|
|
||||||
local git_branch=""
|
|
||||||
if git rev-parse --is-inside-work-tree &>/dev/null; then
|
|
||||||
git_branch=" ($(git branch --show-current 2>/dev/null))"
|
|
||||||
fi
|
|
||||||
print -Pn "\e]2;%m: %~${git_branch}\a"
|
|
||||||
}
|
|
||||||
precmd_functions+=(set_terminal_title)
|
|
||||||
|
|
||||||
# Keybindings
|
|
||||||
bindkey "^[[H" beginning-of-line # Home
|
|
||||||
bindkey "^[OH" beginning-of-line # Home
|
|
||||||
bindkey "^A" beginning-of-line # Ctrl + A
|
|
||||||
bindkey "^[[F" end-of-line # End
|
|
||||||
bindkey "^[OF" end-of-line # End
|
|
||||||
bindkey "^E" end-of-line # Ctrl + E
|
|
||||||
bindkey "^[[3~" delete-char # Del
|
|
||||||
|
|
||||||
# Colour support of ls and friends
|
|
||||||
if [ -x /usr/bin/dircolors ]; then
|
|
||||||
test -r $HOME/.dircolors && eval "$(dircolors -b $HOME/.dircolors)" || eval "$(dircolors -b)"
|
|
||||||
alias ls='ls --color=auto'
|
|
||||||
alias dir='dir --color=auto'
|
|
||||||
alias vdir='vdir --color=auto'
|
|
||||||
alias grep='grep --color=auto'
|
|
||||||
alias fgrep='fgrep --color=auto'
|
|
||||||
alias egrep='egrep --color=auto'
|
|
||||||
fi
|
|
||||||
alias ll='ls -alhF'
|
|
||||||
alias la='ls -Ah'
|
|
||||||
alias l='ls -CFh'
|
|
||||||
|
|
||||||
# A throwaway container, and what takes the space under a directory
|
|
||||||
alias drun='docker run -it --rm'
|
|
||||||
disksize() {
|
|
||||||
du -h --max-depth=1 "${1:-.}" | sort -h
|
|
||||||
}
|
|
||||||
sudo-disksize() {
|
|
||||||
sudo du -h --max-depth=1 "${1:-.}" | sort -h
|
|
||||||
}
|
|
||||||
|
|
||||||
# claude-code
|
|
||||||
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
|
|
||||||
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
|
|
||||||
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
|
|
||||||
# the real claude: exec, sudo and its sh see no alias.
|
|
||||||
if [[ -r /etc/claude-code/agent-account ]]; then
|
|
||||||
() {
|
|
||||||
local agent=$(</etc/claude-code/agent-account)
|
|
||||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
|
||||||
alias claude=claude-agent
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
# mesh-cli
|
|
||||||
# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.
|
|
||||||
# A script, sudo and a document meant to work everywhere say mesh-cli.
|
|
||||||
alias nox=mesh-cli
|
|
||||||
# powerlevel10k
|
|
||||||
# The prompt: powerlevel10k v1.20.0, pinned in this module (novox/hq ADR 0205), and its configuration.
|
|
||||||
[[ ! -f ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme ]] || source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme
|
|
||||||
[[ ! -f ~/.config/powerlevel10k/p10k.zsh ]] || source ~/.config/powerlevel10k/p10k.zsh
|
|
||||||
# zsh-autosuggestions
|
|
||||||
# Suggestions from history as you type, from the distribution's package.
|
|
||||||
[[ ! -f /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh ]] || source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
# zsh-syntax-highlighting
|
|
||||||
# Syntax highlighting of the command line, from the distribution's package. Last, as upstream asks:
|
|
||||||
# it wraps every widget defined before it.
|
|
||||||
[[ ! -f /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh ]] || source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
|
|
||||||
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A placeholder no pass consumes is refused, never written out as text (novox/hq issue 231).
|
|
||||||
//
|
|
||||||
// Each namespace is filled by its own pass with its own pattern, in the fields that pass reads. A
|
|
||||||
// word in that shape that no pattern matched — `${machnie:address}`, `${shel:zsh:first}`, a setting
|
|
||||||
// key no definition could declare such as `${setting:Undeclared}` — was left in the file as it was
|
|
||||||
// written and reached a machine as a value: the predecessor's failure the namespaced placeholders
|
|
||||||
// were meant to end (novox/hq ADR 0164). So the definition is swept, field by field, against the
|
|
||||||
// same table of what each pass fills where.
|
|
||||||
//
|
|
||||||
// **The definition, never the values.** Swept as the manifest wrote it, at the catalogue check and
|
|
||||||
// again at composition before any pass has run: what an operator's setting, a binding or a merged
|
|
||||||
// JSON setting puts into a file is its own software's text — `${env:HOME}` for Log4j, `${timeout:30}`
|
|
||||||
// for Spring — and refusing it there would refuse something its author never wrote, on a machine the
|
|
||||||
// check had passed (novox/hq issue 231, review of mesh-controller #211).
|
|
||||||
//
|
|
||||||
// What is refused:
|
|
||||||
// - a placeholder of a namespace the mesh knows, in a field that namespace's pass does not read: it
|
|
||||||
// would reach the machine as the same text;
|
|
||||||
// - any other `${<known namespace>:`, case-insensitively, unless a shell's parameter operator follows
|
|
||||||
// its colon — `${Machine:address}`, `${machine:.address}`, `${machine: address}`, an unclosed
|
|
||||||
// `${machine:address` — because no pass's pattern takes it;
|
|
||||||
// - a namespace-shaped placeholder of a word the mesh does not know: a lower-case word, a colon, and
|
|
||||||
// a key that begins with a letter or a digit and holds no space, brace or `$`.
|
|
||||||
//
|
|
||||||
// **The shell's own syntax is not that shape, and passes.** `${NAME:-…}` has an upper-case word,
|
|
||||||
// `${(%):-…}` and `${1:-.}` begin with no letter, and a lower-case variable with an operator after its
|
|
||||||
// colon — `${count:-}`, `${trial:+…}`, `${state:=…}` — has no key that begins with a letter or a digit.
|
|
||||||
// And an operator — `:-`, `:=`, `:+`, `:?` — after a word the mesh also uses is the shell's too:
|
|
||||||
// `${PORT:-8080}`, `${SHELL:-/bin/sh}`, `${SECRET:?unset}` and `${dir:-/tmp}` are among the commonest
|
|
||||||
// lines of a script or an env file, and pass whatever the word's case.
|
|
||||||
// What the shape does catch is a zsh modifier (`${path:t}`) or a substring (`${where:0:12}`) in a
|
|
||||||
// resource's own text: shell code of that kind belongs in the module's contributed shell code, which
|
|
||||||
// is not a resource and is never swept (novox/hq ADR 0204).
|
|
||||||
|
|
||||||
// filler is one namespace's pass: the patterns it fills with, and the fields it reads them in, by
|
|
||||||
// resource type ("*" for any type).
|
|
||||||
type filler struct {
|
|
||||||
namespace string
|
|
||||||
patterns []*regexp.Regexp
|
|
||||||
fields map[string][]string
|
|
||||||
// why, when set, is the rule that keeps the namespace out of every other field, said with a
|
|
||||||
// refusal of one written there.
|
|
||||||
why string
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillers is the table of what each pass fills where — read from the passes themselves: settingInto,
|
|
||||||
// dirInto, accessInto, intoFile (whose ${secret:…} the node-engine fills), boundInto, portInto,
|
|
||||||
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
|
|
||||||
// or the sweep refuses the placeholder it would have filled.
|
|
||||||
var fillers = []filler{
|
|
||||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
|
|
||||||
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
|
||||||
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
|
||||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
|
||||||
why: "a secret is never filled into an environment variable or any other field: put it in a file the " +
|
|
||||||
"module declares and mount that (novox/hq ADR 0086)"},
|
|
||||||
{namespace: "bound", patterns: []*regexp.Regexp{bound}, fields: map[string][]string{"file": {"content"}}},
|
|
||||||
{namespace: "port", patterns: []*regexp.Regexp{ofPort}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}},
|
|
||||||
{namespace: "seat", patterns: []*regexp.Regexp{ofSeat, ofSeatReach}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}},
|
|
||||||
{namespace: "machine", patterns: []*regexp.Regexp{ofMachine}, fields: map[string][]string{"*": {"path", "owner", "content", "name", "user", "root", "home"}}},
|
|
||||||
// Placed in a file's content by their holders, and judged there by their own rules
|
|
||||||
// (placeholderProblems, seatPlaceholderProblems).
|
|
||||||
{namespace: "environment", patterns: []*regexp.Regexp{ofEnvironment}, fields: map[string][]string{"*": {"content"}}},
|
|
||||||
{namespace: "shell", patterns: []*regexp.Regexp{ofShell}, fields: map[string][]string{"*": {"content"}}},
|
|
||||||
{namespace: "contribution", patterns: []*regexp.Regexp{ofContribution}, fields: map[string][]string{"*": {"content"}}},
|
|
||||||
// What the machine's modules back up, placed by the backup holder's file (backup_placement.go, issue 794).
|
|
||||||
{namespace: BackupNamespace, patterns: []*regexp.Regexp{ofBackup}, fields: map[string][]string{"*": {"content"}}},
|
|
||||||
// A collection, placed by its collector (collections.go): filled by the provisioner's client.
|
|
||||||
{namespace: CollectedNamespace, patterns: []*regexp.Regexp{ofCollected}, fields: map[string][]string{"*": {"content"}}},
|
|
||||||
// A provider's consumers' credentials (consumers.go): filled by the provisioner's client.
|
|
||||||
{namespace: ConsumersNamespace, patterns: []*regexp.Regexp{ofConsumers}, fields: map[string][]string{"file": {"content"}}},
|
|
||||||
// Filled in what a provider serves (consumer_into_serves.go), never in a resource.
|
|
||||||
{namespace: "consumer", patterns: []*regexp.Regexp{consumerFact}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// reads is whether this pass fills a field of a resource of this type.
|
|
||||||
func (f filler) reads(kind, field string) bool {
|
|
||||||
return oneOf(f.fields[kind], field) || oneOf(f.fields["*"], field)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ofKnownNamespace is any `${<known namespace>:` and what follows it up to its brace or the end of
|
|
||||||
// its line, whatever its case, unless what follows the colon is a shell's parameter operator (`-`,
|
|
||||||
// `=`, `+`, `?`): what remains of one once every pass's pattern is set aside is a misspelling.
|
|
||||||
var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") +
|
|
||||||
`):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`)
|
|
||||||
|
|
||||||
// namespaceShaped is a placeholder in the mesh's shape: a lower-case word, a colon, and a key that
|
|
||||||
// begins with a letter or a digit and holds no space, brace or `$`.
|
|
||||||
var namespaceShaped = regexp.MustCompile(`\$\{[a-z][a-z0-9_-]*:[A-Za-z0-9][^\s{}$]*\}`)
|
|
||||||
|
|
||||||
func namespacesOf(fs []filler) []string {
|
|
||||||
out := make([]string, len(fs))
|
|
||||||
for i, f := range fs {
|
|
||||||
out[i] = f.namespace
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// unconsumedPlaceholders is every placeholder in one resource of a definition that no pass fills
|
|
||||||
// where it stands, each named with the module, the resource, the field and the token. The same
|
|
||||||
// function at the catalogue check and at composition, over the resource as the manifest wrote it.
|
|
||||||
func unconsumedPlaceholders(module string, r map[string]any) []string {
|
|
||||||
kind := fmt.Sprint(r["type"])
|
|
||||||
var problems []string
|
|
||||||
var sweep func(top, field string, v any)
|
|
||||||
sweep = func(top, field string, v any) {
|
|
||||||
switch v := v.(type) {
|
|
||||||
case string:
|
|
||||||
rest := v
|
|
||||||
for _, f := range fillers {
|
|
||||||
for _, p := range f.patterns {
|
|
||||||
if !f.reads(kind, top) {
|
|
||||||
for _, token := range p.FindAllString(rest, -1) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v holds %s in its %s, and ${%s:…} is not filled in this field: "+
|
|
||||||
"it would reach the machine as that text (novox/hq issue 231)%s",
|
|
||||||
module, r["id"], token, field, f.namespace, whereFilled(f)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rest = p.ReplaceAllString(rest, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, token := range ofKnownNamespace.FindAllString(rest, -1) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v holds %s in its %s, which is no placeholder the mesh fills: a "+
|
|
||||||
"misspelt key, or a namespace in the wrong case, would reach the machine as that "+
|
|
||||||
"text (novox/hq issue 231)", module, r["id"], token, field))
|
|
||||||
}
|
|
||||||
rest = ofKnownNamespace.ReplaceAllString(rest, "")
|
|
||||||
for _, token := range namespaceShaped.FindAllString(rest, -1) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s's resource %v holds %s in its %s, and no pass of the mesh fills it: a misspelt "+
|
|
||||||
"placeholder would reach the machine as that text (novox/hq issue 231). The mesh "+
|
|
||||||
"fills ${%s:…}; the shell's own syntax belongs in the module's shell code (ADR 0204)",
|
|
||||||
module, r["id"], token, field, strings.Join(namespacesOf(fillers), ":…}, ${")))
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for i, e := range v {
|
|
||||||
sweep(top, fmt.Sprintf("%s[%d]", field, i), e)
|
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
for _, k := range sortedKeys(v) {
|
|
||||||
sweep(top, field+"."+k, v[k])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, k := range sortedKeys(r) {
|
|
||||||
sweep(k, k, r[k])
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// whereFilled says where a namespace's pass does fill, for a refusal of one written elsewhere.
|
|
||||||
func whereFilled(f filler) string {
|
|
||||||
if f.why != "" {
|
|
||||||
return ". " + strings.ToUpper(f.why[:1]) + f.why[1:]
|
|
||||||
}
|
|
||||||
if len(f.fields) == 0 {
|
|
||||||
return "; it is filled only in what a provider serves"
|
|
||||||
}
|
|
||||||
var where []string
|
|
||||||
for _, kind := range sortedKeys(f.fields) {
|
|
||||||
of := "a " + kind + "'s"
|
|
||||||
if kind == "*" {
|
|
||||||
of = "any resource's"
|
|
||||||
}
|
|
||||||
where = append(where, of+" "+strings.Join(f.fields[kind], ", "))
|
|
||||||
}
|
|
||||||
return "; it is filled in " + strings.Join(where, "; ")
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// How loud a module's unhealthy condition is (novox/hq ADR 0355, extending ADR 0240).
|
|
||||||
//
|
|
||||||
// **A module may declare its unhealthy urgent.** A module whose failure costs people something they rely on
|
|
||||||
// (mail refused or stuck for everyone with a mailbox) says so with `unhealthy`, and its condition
|
|
||||||
// `module.<module>.<machine>.unhealthy` is raised urgent instead of a warning. **Urgent needs a why**, as an
|
|
||||||
// `upgrade` policy other than roll does: a person reading the catalogue sees what is lost while it is broken.
|
|
||||||
|
|
||||||
// The severities a module may declare for its unhealthy condition.
|
|
||||||
const (
|
|
||||||
// UnhealthyUrgent raises the module's unhealthy condition urgent, on every machine it runs on.
|
|
||||||
UnhealthyUrgent = "urgent"
|
|
||||||
// UnhealthyWarning is what a module's unhealthy condition is without the field, said out loud.
|
|
||||||
UnhealthyWarning = "warning"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UnhealthyPolicy is what a module says about how loud its unhealthy condition is: `unhealthy` in its manifest
|
|
||||||
// (novox/hq ADR 0355).
|
|
||||||
type UnhealthyPolicy struct {
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
// Why names what people lose while the module is broken. It is required: a module that says nothing of
|
|
||||||
// why it is loud is refused.
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR
|
|
||||||
// 0355 §2).
|
|
||||||
func (m Manifest) unhealthyProblems() []string {
|
|
||||||
if m.Unhealthy == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
switch m.Unhealthy.Severity {
|
|
||||||
case UnhealthyUrgent, UnhealthyWarning:
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", UnhealthyUrgent, UnhealthyWarning,
|
|
||||||
m.Unhealthy.Severity))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(m.Unhealthy.Why) == "" {
|
|
||||||
problems = append(problems, "unhealthy says why: a module that declares how loud its failure is names "+
|
|
||||||
"what people lose while it is broken")
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnhealthyIsUrgent is whether the module declares its unhealthy condition urgent (novox/hq ADR 0355 §1).
|
|
||||||
func (m Manifest) UnhealthyIsUrgent() bool {
|
|
||||||
return m.Unhealthy != nil && m.Unhealthy.Severity == UnhealthyUrgent
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A key this controller does not know, in a manifest (novox/hq ADR 0262).
|
|
||||||
//
|
|
||||||
// Registration refuses it, as it always has. A manifest the store already holds was registered by a newer
|
|
||||||
// controller, and is read by this one after a rollback: refusing it there failed the whole catalogue, and
|
|
||||||
// with it every plan and every send. Dropping the key silently would be worse — a module running without
|
|
||||||
// something its manifest says. So the manifest is read, the module is left out of every machine's
|
|
||||||
// declaration by name, and the controller raises a condition until it is updated.
|
|
||||||
|
|
||||||
// UnknownFieldError is a key a manifest has that this controller does not know, wherever it is: at the
|
|
||||||
// top of the manifest or inside a block (a state, an offer, a data item's backup, an own secret, a verb).
|
|
||||||
// Typed, because the blocks' decoders wrap the decoder's words in their own.
|
|
||||||
type UnknownFieldError struct {
|
|
||||||
Field string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *UnknownFieldError) Error() string { return e.err.Error() }
|
|
||||||
func (e *UnknownFieldError) Unwrap() error { return e.err }
|
|
||||||
|
|
||||||
// unknownFieldText is how the JSON decoder words a key its target does not have.
|
|
||||||
var unknownFieldText = regexp.MustCompile(`^json: unknown field "([^"]*)"$`)
|
|
||||||
|
|
||||||
// typedUnknown is err as an UnknownFieldError when it is the decoder refusing an unknown key, else err.
|
|
||||||
func typedUnknown(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if m := unknownFieldText.FindStringSubmatch(err.Error()); m != nil {
|
|
||||||
return &UnknownFieldError{Field: m[1], err: err}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// asUnknownField is the unknown key err is about, at any depth, or nil.
|
|
||||||
func asUnknownField(err error) *UnknownFieldError {
|
|
||||||
var u *UnknownFieldError
|
|
||||||
if errors.As(typedUnknown(err), &u) {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnknownFieldReason is why a module whose stored manifest has a key this controller does not know is
|
|
||||||
// left out of a machine's declaration, or "" when it has none.
|
|
||||||
func UnknownFieldReason(m Manifest) string {
|
|
||||||
if m.unknown == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return m.Module + " uses a field this controller does not know (" + m.unknown + "); it is left out " +
|
|
||||||
"until the controller is updated: nothing of it is changed on its machines and its contributions to " +
|
|
||||||
"other modules and its open ports stop. Its data is still backed up as this controller reads it, " +
|
|
||||||
"which may not be what its newer manifest asks, and it still provides what it provides " +
|
|
||||||
"(novox/hq ADR 0262)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupView is what of a left-out module still reaches its machine: its data, so the backup holder
|
|
||||||
// keeps copying it, and the directories and accesses its data items name. No contribution, shell code
|
|
||||||
// or environment of its own: those are what leaving it out stops.
|
|
||||||
func backupView(m Manifest) Manifest {
|
|
||||||
view := Manifest{Module: m.Module, Data: m.Data, Accesses: m.Accesses, bestEffort: true}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
view.Resources = append(view.Resources, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
// prunedFields is a stored manifest's fields with every key this controller does not know taken out,
|
|
||||||
// and the keys taken out (novox/hq ADR 0262). A second pass, after the strict one refused: each
|
|
||||||
// top-level field is decoded alone, and where an entry inside it has an unknown key, the one
|
|
||||||
// occurrence whose removal moves the decoder past it is removed — never a key of the same name that
|
|
||||||
// the entry around it knows. So a left-out module still provides what it provides, and its data and
|
|
||||||
// directories are still read, which its backup lines are made from.
|
|
||||||
func prunedFields(keys map[string]json.RawMessage) (manifestFields, []string, error) {
|
|
||||||
var removed []string
|
|
||||||
tree := map[string]any{}
|
|
||||||
for k, raw := range keys {
|
|
||||||
var v any
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.UseNumber()
|
|
||||||
if err := dec.Decode(&v); err != nil {
|
|
||||||
return manifestFields{}, nil, err
|
|
||||||
}
|
|
||||||
tree[k] = v
|
|
||||||
}
|
|
||||||
for _, k := range sortedAnyKeys(tree) {
|
|
||||||
for tries := 0; ; tries++ {
|
|
||||||
err := decodesAlone(k, tree[k])
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
unknown := asUnknownField(err)
|
|
||||||
if unknown == nil || tries > 64 {
|
|
||||||
return manifestFields{}, nil, err
|
|
||||||
}
|
|
||||||
if unknown.Field == k {
|
|
||||||
delete(tree, k)
|
|
||||||
removed = append(removed, k)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
path, ok := removalThatHelps(k, tree[k], unknown.Field, err.Error())
|
|
||||||
if !ok {
|
|
||||||
// The same key unknown in two entries alike: no one removal changes the words.
|
|
||||||
// Every occurrence goes, and the field is judged again.
|
|
||||||
if removeEvery(tree[k], unknown.Field) == 0 {
|
|
||||||
return manifestFields{}, nil, err
|
|
||||||
}
|
|
||||||
path = "…." + unknown.Field
|
|
||||||
}
|
|
||||||
removed = append(removed, k+path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(tree)
|
|
||||||
if err != nil {
|
|
||||||
return manifestFields{}, nil, err
|
|
||||||
}
|
|
||||||
var fields manifestFields
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&fields); err != nil {
|
|
||||||
return manifestFields{}, nil, err
|
|
||||||
}
|
|
||||||
return fields, removed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodesAlone is whether one top-level field decodes strictly on its own.
|
|
||||||
func decodesAlone(k string, v any) error {
|
|
||||||
raw, err := json.Marshal(map[string]any{k: v})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var fields manifestFields
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
return dec.Decode(&fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// removalThatHelps removes, from v, the one occurrence of key whose removal changes what the strict
|
|
||||||
// decoder says of field k, and says where it was. Every other occurrence is left as it was.
|
|
||||||
func removalThatHelps(k string, v any, key, said string) (string, bool) {
|
|
||||||
var found bool
|
|
||||||
var where string
|
|
||||||
var walk func(node any, at string) bool
|
|
||||||
walk = func(node any, at string) bool {
|
|
||||||
switch n := node.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
if value, has := n[key]; has {
|
|
||||||
delete(n, key)
|
|
||||||
// Accepted only when the decoder is past it: nothing left, or an unknown key said
|
|
||||||
// elsewhere. A different kind of error means the removal broke the entry; the same
|
|
||||||
// words mean this was not the occurrence it refused.
|
|
||||||
err := decodesAlone(k, v)
|
|
||||||
if err == nil || (asUnknownField(err) != nil && err.Error() != said) {
|
|
||||||
found, where = true, at+"."+key
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
n[key] = value
|
|
||||||
}
|
|
||||||
for _, sub := range sortedAnyKeys(n) {
|
|
||||||
if walk(n[sub], at+"."+sub) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for i, item := range n {
|
|
||||||
if walk(item, fmt.Sprintf("%s[%d]", at, i)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
walk(v, "")
|
|
||||||
return where, found
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeEvery removes key from every object in v, and says how many it removed.
|
|
||||||
func removeEvery(v any, key string) int {
|
|
||||||
n := 0
|
|
||||||
switch node := v.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
if _, has := node[key]; has {
|
|
||||||
delete(node, key)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
for _, sub := range node {
|
|
||||||
n += removeEvery(sub, key)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, item := range node {
|
|
||||||
n += removeEvery(item, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR
|
|
||||||
// 0218 §2).
|
|
||||||
//
|
|
||||||
// **Rolled out by default, one machine first and gated.** With the gate on the first machine and the
|
|
||||||
// rollback after it (to-be 45 §8), a build that moves is sent to one machine, judged there by its own
|
|
||||||
// health, and only then to the rest — or put back there, said, and sent nowhere else. Recording an
|
|
||||||
// upgrade and waiting for a person to push it is kept where a module says why, and where the mesh knows
|
|
||||||
// a rollback cannot undo what a new build does.
|
|
||||||
|
|
||||||
// The policies a module may declare.
|
|
||||||
const (
|
|
||||||
// PolicyRoll sends one machine first, judges it at the gate, then the rest.
|
|
||||||
PolicyRoll = "roll"
|
|
||||||
// PolicyTogether sends every machine running the module at once — for a module that must change
|
|
||||||
// everywhere in the same minute. Still judged, on every machine, after.
|
|
||||||
PolicyTogether = "together"
|
|
||||||
// PolicyRecord builds and sends nothing: the machines running it are behind until a person pushes.
|
|
||||||
PolicyRecord = "record"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UpgradePolicy is what a module says about how its new builds reach its machines: `upgrade` in its
|
|
||||||
// manifest.
|
|
||||||
type UpgradePolicy struct {
|
|
||||||
Policy string `json:"policy"`
|
|
||||||
// Why is required for anything but roll: a person reading the catalogue sees why this module waits
|
|
||||||
// for them, or why it changes everywhere at once.
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Manifest) upgradeProblems() []string {
|
|
||||||
if m.Upgrade == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch m.Upgrade.Policy {
|
|
||||||
case PolicyRoll:
|
|
||||||
case PolicyTogether, PolicyRecord:
|
|
||||||
if strings.TrimSpace(m.Upgrade.Why) == "" {
|
|
||||||
return []string{fmt.Sprintf("upgrade %q says why: a module that does not roll out one machine first "+
|
|
||||||
"names the reason a person reads", m.Upgrade.Policy)}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return []string{fmt.Sprintf("upgrade is %q, %q or %q, not %q", PolicyRoll, PolicyTogether, PolicyRecord,
|
|
||||||
m.Upgrade.Policy)}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where a policy came from, as `upgrade` says it.
|
|
||||||
const (
|
|
||||||
FromPerson = "person"
|
|
||||||
FromModule = "module"
|
|
||||||
FromBus = "the bus"
|
|
||||||
FromData = "irreplaceable data"
|
|
||||||
FromDefault = "default"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DerivedUpgrade is the policy a module's manifest gives it when no person has chosen one, with where
|
|
||||||
// it came from and why (ADR 0236):
|
|
||||||
//
|
|
||||||
// - **the bus is never rolled**: a module that provides the mesh's bus records, whatever it says — its
|
|
||||||
// upgrade is a planned step a person starts (to-be 45 §8);
|
|
||||||
// - a module that says its policy has it;
|
|
||||||
// - a module that keeps irreplaceable data records — sending the previous build cannot undo what a new
|
|
||||||
// one did to data that cannot be had again, so a person takes it, after a backup;
|
|
||||||
// - everything else rolls out, one machine first.
|
|
||||||
func DerivedUpgrade(m Manifest) (policy, from, why string) {
|
|
||||||
if ProvidesBus(m) {
|
|
||||||
return PolicyRecord, FromBus, "the bus is replaced only as a planned step a person starts (`bus upgrade`): " +
|
|
||||||
"its streams are snapshotted first and checked after"
|
|
||||||
}
|
|
||||||
if m.Upgrade != nil && m.Upgrade.Policy != "" {
|
|
||||||
return m.Upgrade.Policy, FromModule, m.Upgrade.Why
|
|
||||||
}
|
|
||||||
if item := m.irreplaceable(); item != "" {
|
|
||||||
return PolicyRecord, FromData, "it keeps irreplaceable data (" + item + "): a rollback cannot undo what a new " +
|
|
||||||
"build does to it, so a person takes each build, after a backup"
|
|
||||||
}
|
|
||||||
return PolicyRoll, FromDefault, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProvidesBus is whether a manifest provides the mesh's bus.
|
|
||||||
func ProvidesBus(m Manifest) bool {
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.Name == "mesh-bus" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// irreplaceable names the first irreplaceable data the module keeps, its own or its consumers', or
|
|
||||||
// nothing.
|
|
||||||
func (m Manifest) irreplaceable() string {
|
|
||||||
if m.Data == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, it := range m.Data.Own {
|
|
||||||
if it.Class == ClassIrreplaceable {
|
|
||||||
return it.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for provision, c := range m.Data.Consumers {
|
|
||||||
if c.Class == ClassIrreplaceable {
|
|
||||||
return "its consumers' " + provision
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for provision, k := range m.Data.KeptBy {
|
|
||||||
if k.Class == ClassIrreplaceable {
|
|
||||||
return "what it keeps with " + provision
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,764 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
|
||||||
// its answer (novox/hq ADR 0132, design 33 §2).
|
|
||||||
//
|
|
||||||
// **A name alone is not callable by something that has never seen the mesh before**, which is the
|
|
||||||
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
|
|
||||||
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
|
|
||||||
// a seat's idea of an argument and the caller's.
|
|
||||||
//
|
|
||||||
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
|
|
||||||
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
|
|
||||||
// nothing downstream cares which was written.
|
|
||||||
type Verb struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Input map[string]any `json:"input,omitempty"`
|
|
||||||
Output map[string]any `json:"output,omitempty"`
|
|
||||||
// Optional marks a verb added to a mesh seat whose holder lives in another repository (design 33 §7,
|
|
||||||
// additive within a version): a holder that serves it is accepted, and one that does not yet still
|
|
||||||
// holds the seat. Without it the addition would be a deadlock — this controller refusing the holder that
|
|
||||||
// does not serve the verb, the controller before it refusing the holder that does — and every check of
|
|
||||||
// the catalogue between the two would fail on a module nobody touched. Once every holder serves it,
|
|
||||||
// the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the
|
|
||||||
// seat set's protocol is the compiled one.
|
|
||||||
Optional bool `json:"-"`
|
|
||||||
// Replaces are the shell commands this verb is the mesh's way to do, each as it is typed —
|
|
||||||
// `journalctl`, `systemctl restart` — so the agent finds the verb by the command it would have run
|
|
||||||
// and is told to call it instead (novox/hq ADR 0245): the mesh MCP server's search matches them, the agent's
|
|
||||||
// instructions list them, and the guard on its shell names the verb when it refuses a work-around.
|
|
||||||
// A command line is matched by its words in order, the first being the command's name; `edit
|
|
||||||
// /etc/hosts` and `HOSTALIASES` name the guard's two local work-arounds for a mesh name.
|
|
||||||
Replaces []string `json:"replaces,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verb) UnmarshalJSON(raw []byte) error {
|
|
||||||
trimmed := bytes.TrimSpace(raw)
|
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
|
||||||
var name string
|
|
||||||
if err := json.Unmarshal(trimmed, &name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*v = Verb{Name: name}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
|
|
||||||
// describe a tool nobody can call and refuse nothing.
|
|
||||||
type plain Verb
|
|
||||||
var p plain
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&p); err != nil {
|
|
||||||
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", typedUnknown(err))
|
|
||||||
}
|
|
||||||
if p.Name == "" {
|
|
||||||
return fmt.Errorf("a served verb has no name: %s", trimmed)
|
|
||||||
}
|
|
||||||
*v = Verb(p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
|
|
||||||
func VerbNames(verbs []Verb) []string {
|
|
||||||
out := make([]string, 0, len(verbs))
|
|
||||||
for _, v := range verbs {
|
|
||||||
out = append(out, v.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
|
|
||||||
const ControllerSeatName = "mesh-controller"
|
|
||||||
|
|
||||||
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
|
|
||||||
//
|
|
||||||
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
|
|
||||||
// is a command the controller's binary already answers, run by the holder of the seat with the
|
|
||||||
// arguments below and answered with what the command printed. A verb here is a contract every future
|
|
||||||
// holder must implement, which is why the list is short and made of what an operator asks weekly.
|
|
||||||
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
|
|
||||||
var ControllerVerbs = []Verb{
|
|
||||||
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
|
|
||||||
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "calls", Description: "The calls this controller answered lately and what came of each — " +
|
|
||||||
"one still running, one that finished after its caller was told it was running, one whose answer " +
|
|
||||||
"the bus refused — and, given a call's id, its whole answer (novox/hq issue 265). A call that has " +
|
|
||||||
"not finished within ten seconds answers that it is running, with its id; this is where it ends.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"call": "a call's id, as a running answer or `calls` gives it: that call and its whole answer",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
|
|
||||||
"machines are behind what the mesh would send them.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}),
|
|
||||||
Replaces: []string{"hostnamectl", "uptime"}},
|
|
||||||
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
|
|
||||||
"and which machines run it.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
|
|
||||||
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "one module's name; every module when absent",
|
|
||||||
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
|
||||||
"limit": "how many builds to list (default 20); not with log",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
|
||||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " +
|
|
||||||
"or, given wait, one plan followed until it moves.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
|
||||||
"wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " +
|
|
||||||
"(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)",
|
|
||||||
"until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)",
|
|
||||||
"bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " +
|
|
||||||
"inside what a call is answered within",
|
|
||||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
|
||||||
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
|
||||||
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
|
||||||
"go": "a walk's id that waits for its delivery's word: start it by hand, in place of mesh-delivery — with why, " +
|
|
||||||
"recorded in the hand-act log (novox/hq ADR 0239)",
|
|
||||||
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
|
||||||
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
|
||||||
"modules": "with repository: or the modules it would change, comma-separated",
|
|
||||||
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
|
|
||||||
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
|
|
||||||
"data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " +
|
|
||||||
"(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " +
|
|
||||||
"without it the answer is text (novox/hq ADR 0450 §2)",
|
|
||||||
"head": "with data: the commit at hand",
|
|
||||||
"base": "with data: the branch it merges into (default main)",
|
|
||||||
"removed": "with data: the files among paths the change deletes, comma-separated",
|
|
||||||
"limit": "how many plans to list (default 10); only when listing",
|
|
||||||
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
|
|
||||||
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
|
|
||||||
}, nil, "data")},
|
|
||||||
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
|
|
||||||
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
|
|
||||||
{Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " +
|
|
||||||
"nothing (novox/hq ADR 0239): what it moves and builds after them in tiers, what each machine receives and " +
|
|
||||||
"what waits there, the steps that are not an ordinary send, and whether the gate would run.",
|
|
||||||
Input: schema(map[string]string{"repository": "owner/repository", "head": "the commit at hand",
|
|
||||||
"base": "the branch it merges into (default main)",
|
|
||||||
"paths": "the files it changes, comma-separated, from the repository's root",
|
|
||||||
"module-dirs": "the directories holding a module.json at the head, comma-separated, as the forge says them",
|
|
||||||
"removed": "the files among paths it deletes, comma-separated"}, []string{"repository", "paths"})},
|
|
||||||
{Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0419): its members in the order " +
|
|
||||||
"they are delivered, every pair and why — built by, verb before caller, version skew, engine before " +
|
|
||||||
"controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " +
|
|
||||||
"cycle, with every module and edge of it, is the one refusal.",
|
|
||||||
Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " +
|
|
||||||
"module_dirs, module_dirs_said, removed}]"}, []string{"members"})},
|
|
||||||
{Name: "delivery-check", Description: "Ask the build seat for a delivery group's composed check (novox/hq ADR " +
|
|
||||||
"0239): every member's head laid over the mesh in turn and judged as one future state. Answers the ask's id; " +
|
|
||||||
"the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " +
|
|
||||||
"again as its pull request is.",
|
|
||||||
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
|
|
||||||
"members": "the members, as JSON, as delivery-order takes them",
|
|
||||||
"ready": "\"true\" when the pull request is marked ready: its check is queued before every other check (novox/hq ADR 0406 §3)"},
|
|
||||||
[]string{"members"})},
|
|
||||||
{Name: "deliver", Description: "The delivery's word that a walk waiting for it may start (novox/hq ADR 0239): its " +
|
|
||||||
"first tier is asked at the controller's next pass. Refused for a walk that waits for nobody.",
|
|
||||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "what lets it go: its turn, a person's release"},
|
|
||||||
[]string{"plan"})},
|
|
||||||
{Name: "delivery-stop", Description: "End a walk on its delivery's word (novox/hq ADR 0239): failed, said as stopped " +
|
|
||||||
"by whom and why; what it asked still builds and registers, nothing further is asked or sent. Given merges to " +
|
|
||||||
"leave out, every other merge the walk answered is walked again — in the open batch of its kind, or a new one — " +
|
|
||||||
"without them, without each later merge of their repository, which contains one, and without each merge moving " +
|
|
||||||
"a module one moves, each left out too and named (novox/hq ADR 0299); a batch not yet cut drops the merges. The " +
|
|
||||||
"answer names the batch, the merges walked again, the merges left out, the modules the stopped walk sent that " +
|
|
||||||
"stay on their machines, and the merges elsewhere that will still deliver a stopped change.",
|
|
||||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery",
|
|
||||||
"without": "merges to leave out, <repository>@<commit>, comma-separated"},
|
|
||||||
[]string{"plan", "why"})},
|
|
||||||
{Name: "delivery-backups-again", Description: "Ask again the backups a walk was held for (novox/hq ADR 0404 " +
|
|
||||||
"decision 4): a walk whose backup before its send failed or did not answer is held, nothing asked or sent; this " +
|
|
||||||
"clears those failed backups and the next look at the plans asks them again, and only them. Refused for a walk " +
|
|
||||||
"with no failed backup. The delivery's release calls it. The answer names the walk and the backups asked again.",
|
|
||||||
Input: schema(map[string]string{"plan": "the held walk's id", "why": "a person's reason, as release passes it",
|
|
||||||
"by": "who released the delivery"}, []string{"plan", "why"})},
|
|
||||||
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
|
|
||||||
"last ended ones, each whole — its tiers, each module's state, first machines and first-node gate with its readings, and its " +
|
|
||||||
"phases from its merge to every machine running it (novox/hq ADR 0282) — and whether the delivery seat has a " +
|
|
||||||
"holder on record. Given a plan, that one.",
|
|
||||||
Input: schema(map[string]string{"plan": "one walk's id", "limit": "how many ended walks beside the open ones (default 50)"},
|
|
||||||
nil)},
|
|
||||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
|
|
||||||
"or, with files, the files it would be given; or, with diff, what a push would change there against what " +
|
|
||||||
"it was last sent: resources added, removed and changed, a container's changed fields (it is recreated), " +
|
|
||||||
"and any mount whose directory on the machine would move (novox/hq ADR 0217).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name",
|
|
||||||
"files": "\"true\": the files this machine would be given, instead of the declaration as JSON",
|
|
||||||
"diff": "\"true\": what a push would change on this machine, against what it was last sent; not with files",
|
|
||||||
}, []string{"node"}, "files", "diff")},
|
|
||||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
|
||||||
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207). " +
|
|
||||||
"A module not registered yet is looked for (novox/hq issue 325, ADR 0261): while its build runs — a merge " +
|
|
||||||
"that adds a module asks for one — the assignment is kept as pending and the controller makes it when the " +
|
|
||||||
"build registers it; `status` lists it, and if the build fails it expires with why and raises a condition. " +
|
|
||||||
"A module known and not built is said, and build asks for its build; a name with no module and no build " +
|
|
||||||
"request kept is refused with the closest names.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name",
|
|
||||||
"module": "the module's name; several comma-separated are judged together",
|
|
||||||
"build": "\"true\": for a module known and not built (its last build failed, or was never asked), ask " +
|
|
||||||
"for its build and keep this assignment pending until the build registers it"},
|
|
||||||
[]string{"node", "module"}, "build")},
|
|
||||||
{Name: "unassign", Description: "Take a module off a machine. Refused when it holds a seat a module left there depends on.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name",
|
|
||||||
"module": "the module's name; several comma-separated are judged together"}, []string{"node", "module"})},
|
|
||||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
|
||||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name",
|
|
||||||
"provision": "the provision, as the consumer requires it",
|
|
||||||
"from": "the node the chosen provider runs on",
|
|
||||||
"module": "the module providing it there",
|
|
||||||
}, []string{"node", "provision", "from", "module"})},
|
|
||||||
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
|
||||||
{Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " +
|
|
||||||
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " +
|
|
||||||
"Answers at once that it is running, with a call id: `calls` with that id says what it sent and, for a " +
|
|
||||||
"machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " +
|
|
||||||
"once it reports within wait (a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
|
|
||||||
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
|
|
||||||
"running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " +
|
|
||||||
"`plan` with diff, and name the machine with move to send it.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name; without it, every machine that is behind",
|
|
||||||
"move": "with node: the modules whose data may move with this push, comma-separated (optional)",
|
|
||||||
"wait": "with node: how long the call waits for the machine to report how it applied what it was sent, as \"90s\" or \"2m\"; \"0\" waits for nothing; without it, the controller's setting push-wait (optional)",
|
|
||||||
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
|
|
||||||
"why": "why this is pushed by hand: recorded in the hand-act log",
|
|
||||||
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
|
|
||||||
}, []string{"why"}, "behind")},
|
|
||||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " +
|
|
||||||
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
|
||||||
"name: made anew and the machine sent, so the module starts again on it — for a secret its definition " +
|
|
||||||
"says it reads at start, whether the mesh made the value or a person gave it (novox/hq ADR 0228); one " +
|
|
||||||
"an outside party issued, or one the module applies to a backend, is refused with the reason.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"provision": "a pair credential: the provision whose credential to replace",
|
|
||||||
"consumer": "with provision: only the holder on this machine (optional)",
|
|
||||||
"node": "an own secret: the machine",
|
|
||||||
"module": "an own secret: the module; with provision: only this consuming module's credential (optional)",
|
|
||||||
"secret": "an own secret: its name in the module's definition",
|
|
||||||
"why": "an own secret: why it is rotated — recorded in the hand-act log (optional)",
|
|
||||||
"cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " +
|
|
||||||
"§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " +
|
|
||||||
"the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " +
|
|
||||||
"module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " +
|
|
||||||
"the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " +
|
|
||||||
"The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine the module runs on, which the secret is sealed to",
|
|
||||||
"module": "the module's name",
|
|
||||||
"secret": "the own secret's name in the module's definition",
|
|
||||||
"at": "the machine the operator sits at, where the prompt opens",
|
|
||||||
}, []string{"node", "module", "secret", "at"})},
|
|
||||||
{Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " +
|
|
||||||
"server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " +
|
|
||||||
"0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " +
|
|
||||||
"value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " +
|
|
||||||
"machines when it changed. Carries no value and answers none; a value every holder already holds is not " +
|
|
||||||
"sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"provision": "the provision whose provider's software issues its credential, e.g. plex-api",
|
|
||||||
}, []string{"provision"})},
|
|
||||||
{Name: "secret-move", Description: "Move every secret the controller holds into the vault, as it is (novox/hq " +
|
|
||||||
"ADR 0349 clause 7, design 57's V3): each machine's node-engine opens what was sealed to it and hands it to " +
|
|
||||||
"the vault, which takes it from that machine alone, never over a value it holds, and reads it back; the " +
|
|
||||||
"record of asks at a desk is copied once. No value is rotated, in the arguments or in the answer, and " +
|
|
||||||
"nothing points at the vault because of it. Answers, per machine, what moved, what was there already and " +
|
|
||||||
"what did not move and why; a machine not answering is moved when it is next heard from. Run again, it " +
|
|
||||||
"moves what is left.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "only this machine's secrets (optional; every machine's without it)",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
|
|
||||||
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
|
|
||||||
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
|
|
||||||
"and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " +
|
|
||||||
"module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " +
|
|
||||||
"Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " +
|
|
||||||
"that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " +
|
|
||||||
"open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " +
|
|
||||||
"announced on every channel. Then push the machine.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine the module runs on, which the secret is sealed to",
|
|
||||||
"module": "the module's name",
|
|
||||||
"secret": "the own secret's name in the module's definition",
|
|
||||||
"at": "the machine the operator sits at, where the prompt opens; the module's machine when absent",
|
|
||||||
}, []string{"node", "module", "secret"})},
|
|
||||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
|
||||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
|
||||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine that runs the module",
|
|
||||||
"module": "the module's name",
|
|
||||||
}, []string{"node", "module"})},
|
|
||||||
{Name: "token", Description: "Refused through a verb since novox/hq ADR 0266: the one-time token a machine " +
|
|
||||||
"joins with is answered to its caller, and whoever may call a verb includes agents. Issue it at the " +
|
|
||||||
"controller's terminal: `mesh-controller token issue --new <name> --overlay-key <public half>`.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "a machine the mesh already has a record for",
|
|
||||||
"new": "or the name of a machine to create the record for",
|
|
||||||
"overlay_key": "the public half of the machine's tunnel key",
|
|
||||||
"for": "how long it may be used, as a duration (default 1h)",
|
|
||||||
"adopted": "\"true\" when the machine is in use and joins adopted",
|
|
||||||
}, nil, "adopted"),
|
|
||||||
Replaces: []string{"mesh-controller token issue", "wg set"}},
|
|
||||||
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
|
|
||||||
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
|
|
||||||
"then every value the module gives a default or a layer sets, with where it comes from: the module's default, " +
|
|
||||||
"the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " +
|
|
||||||
"module's preferences and each machine's value; " +
|
|
||||||
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
|
|
||||||
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
|
|
||||||
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
|
|
||||||
"what its definition says; a cleared or replaced layer is kept in the history. A trusted setting — " +
|
|
||||||
"places, accesses, what a provider serves, what a trusted file asks for, the whole layer of a module " +
|
|
||||||
"with a trusted mergeable file — is refused through this verb (novox/hq issue 339, 340) and PROPOSED " +
|
|
||||||
"instead (novox/hq ADR 0277): with propose, the values (or clear) are put to the operator on a channel " +
|
|
||||||
"that proves who answers, with every key and its exact new value, and the layer is set only on their " +
|
|
||||||
"Approve; the answer names the proposal, its fingerprint and when it expires, and nothing changes until " +
|
|
||||||
"then. With proposals, every proposal and where each stands; with proposal, one whole. Once move-config has " +
|
|
||||||
"run, a module in the config form forwards to mesh-config (issue 664): show lists what config lists, set and " +
|
|
||||||
"clear set and clear each value as the module's own on the machine (none for the whole mesh), placement " +
|
|
||||||
"staying beside the assignment, and propose proposes each value through mesh-config.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "the module's name; with list, only that module's",
|
|
||||||
"values": "the settings as a JSON object, for set or propose",
|
|
||||||
"node": "one machine; the whole mesh when absent",
|
|
||||||
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
|
|
||||||
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
|
|
||||||
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
|
|
||||||
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
|
|
||||||
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
|
|
||||||
"propose": "\"true\": propose the values (or clear) to the operator instead of setting them (novox/hq ADR 0277)",
|
|
||||||
"proposals": "\"true\": every settings proposal, newest first, and where each stands",
|
|
||||||
"proposal": "a proposal's id: that proposal whole, its values and the layer it was shown against",
|
|
||||||
}, nil, "clear", "replace", "history", "propose", "proposals")},
|
|
||||||
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
|
|
||||||
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
|
|
||||||
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
|
|
||||||
"status, version, help, seats, healers, hand-acts, durations, collection, images, artifacts, data, builds, " +
|
|
||||||
"queue, plan, plans (not stop/close/go), doctor (not run), conditions list/show/history, node list/show, " +
|
|
||||||
"module list, settings show/preferences, retire list, cleanup list, delivery plan/walks, bus, mirrors (not " +
|
|
||||||
"--record/--confirm). What writes has its named verb; what sets a key, issues a credential or a token, or " +
|
|
||||||
"accepts, recovers or exports a secret is the controller's terminal's alone.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
|
||||||
}, []string{"command"})},
|
|
||||||
// The build queue, controlled by hand (novox/hq ADR 0219). Every verb that drops an ask leaves a
|
|
||||||
// failed outcome for it, so a plan waiting on it fails visibly instead of hanging.
|
|
||||||
{Name: "queue", Description: "Every ask in the build queue: waiting, in flight (on which machine, for how long), " +
|
|
||||||
"and dead (handed out as often as allowed and never settled) — each with its id, repository, path, ref and when it was asked. " +
|
|
||||||
"Answers the table as text and, as data, each ask's state, kind (check or build), repository, commit, machine, since " +
|
|
||||||
"and position among the waiting (novox/hq issue 455).",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "cancel", Description: "Drop one waiting or dead ask from the build queue; its outcome is recorded failed, " +
|
|
||||||
"cancelled by hand, and a plan that asked for it fails. One in flight is refused: `kill` ends it where it runs.",
|
|
||||||
Input: schema(map[string]string{"id": "the ask's build id, as `queue` lists it"}, []string{"id"})},
|
|
||||||
{Name: "clear", Description: "Cancel every waiting ask in the build queue — and with dead, every dead one too — each " +
|
|
||||||
"recorded failed, cancelled by hand. Never touches one in flight.",
|
|
||||||
Input: schema(map[string]string{"dead": "\"true\" to cancel the dead asks as well"}, nil, "dead")},
|
|
||||||
{Name: "rebuild", Description: "Ask a module's current source again under a new id — the branch it follows — or, given a " +
|
|
||||||
"build's id, that build's repository, path and ref. A module a plan holds unbuilt or failed joins that plan. Answers the new id.",
|
|
||||||
Input: schema(map[string]string{"what": "a module's name, or a build's id"}, []string{"what"})},
|
|
||||||
{Name: "replay", Description: "Ask a recorded build's repository and path again at the commit it built, under a new id. " +
|
|
||||||
"A dry run unless register: nothing recorded or registered. Registering is refused when a newer build of the module " +
|
|
||||||
"is registered — it would roll the older commit out (novox/hq issue 207) — unless older says so.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"id": "the build's id",
|
|
||||||
"register": "\"true\" to register what it builds",
|
|
||||||
"older": "\"true\", with register: even though a newer build of the module is registered",
|
|
||||||
}, []string{"id"}, "register", "older")},
|
|
||||||
// novox/hq issue 686: a builder ran a branch's store tests for the first time in its pull request's merge check.
|
|
||||||
{Name: "try", Description: "Try a pushed branch or commit before its pull request (novox/hq issue 686): the build " +
|
|
||||||
"seat merges it into the repository's trunk and runs the repository's own merge-check.sh on the merge, in the " +
|
|
||||||
"toolchain containers and against the throwaway store and bus a pull request's merge check gets. Answers at " +
|
|
||||||
"once with an id; asked again with that id it waits up to 20 s and answers pass, fail or error, how long it " +
|
|
||||||
"took and the lines naming what failed — or that it is still waiting or running, to ask again. Sets no " +
|
|
||||||
"status on the forge, registers and builds nothing, runs no merge gate, counts as a check in the build queue " +
|
|
||||||
"(after deliveries and after every pull request's check, however long it waits) and is bounded as one. One " +
|
|
||||||
"try of a commit at a time — a second ask answers the first's id — and at most four waiting or running in " +
|
|
||||||
"all: a fifth is refused. Only for a repository of the mesh; the ref must be on the forge.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"repository": "the repository on the forge, owner/name; with ref",
|
|
||||||
"ref": "the branch or commit to try, as pushed to the forge",
|
|
||||||
"base": "the branch it is merged into (the repository's default branch when none is named). The " +
|
|
||||||
"merge-check.sh that runs is that branch's, never the ref's own: the same trust as a pull request's base",
|
|
||||||
"tests": "only the tests this `go test -run` pattern names, where the repository's merge-check.sh reads " +
|
|
||||||
"MESH_CHECK_TESTS; a script that does not runs whole",
|
|
||||||
"store-only": "\"true\": only the repository's store tests, where its merge-check.sh reads " +
|
|
||||||
"MESH_CHECK_STORE_ONLY",
|
|
||||||
"id": "instead of a repository: the id a try answered, to read what came of it",
|
|
||||||
}, nil, "store-only"),
|
|
||||||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
|
||||||
// novox/hq ADR 0350 §5: asked by the delivery's owner when the forge says a pull request closed unmerged.
|
|
||||||
{Name: "forget-checks", Description: "Let go of every build a pull request's merge checks made for its merge in " +
|
|
||||||
"the artifact store's scratch namespace (novox/hq ADR 0350 §5): asked when it closed unmerged; a merged one's go " +
|
|
||||||
"when the walk answering its merge ends, and any check build goes after a week.",
|
|
||||||
Input: schema(map[string]string{"owner": "the repository's owner", "repo": "the repository",
|
|
||||||
"number": "the pull request's number"}, []string{"owner", "repo", "number"})},
|
|
||||||
{Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " +
|
|
||||||
"announces it failed, killed by hand — settled, never handed to another machine.",
|
|
||||||
Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})},
|
|
||||||
{Name: "pause", Description: "The build seat's holder on one machine — or every holder — takes no new build until resumed; " +
|
|
||||||
"a build running finishes. Kept across a restart of the holder. A plan waiting on a paused seat says so and is not late.",
|
|
||||||
Input: schema(map[string]string{"node": "one machine; every holder when absent"}, nil)},
|
|
||||||
{Name: "resume", Description: "The build seat's holder on one machine — or every holder — takes builds again.",
|
|
||||||
Input: schema(map[string]string{"node": "one machine; every holder when absent"}, nil)},
|
|
||||||
// Acts done by hand, and what the bounds are set from (novox/hq to-be 45 §7, Phase 0).
|
|
||||||
{Name: "hand-act", Description: "Record an act done by hand outside the mesh — a container restarted, a file " +
|
|
||||||
"edited, a service started on a machine — with why and its cause, in the hand-act log beside the pushes and " +
|
|
||||||
"plans ended by hand (novox/hq to-be 45 §7). A cause recorded twice in a fortnight is a healer wanted.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"what": "what was done, in a line",
|
|
||||||
"why": "why it had to be done by hand",
|
|
||||||
"cause": "the cause in a word, or a condition's kind — the word a second act for the same reason uses",
|
|
||||||
"condition": "the key of the condition it addressed, if any (optional)",
|
|
||||||
}, []string{"what", "why", "cause"})},
|
|
||||||
{Name: "drill", Description: "Record a drill — something broken on purpose to see the mesh raise and clear it: " +
|
|
||||||
"a module stopped, a process killed — with what it tests, in the hand-act log. A drill is a person's " +
|
|
||||||
"deliberate test, never a repair: no healer is wanted for it however often it is run (S15).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"what": "what was done on purpose, in a line",
|
|
||||||
"why": "what the drill tests",
|
|
||||||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
|
||||||
}, []string{"what", "why"})},
|
|
||||||
{Name: "warranted", Description: "Record in the hand-act log what a module did on the operator's warrant (novox/hq " +
|
|
||||||
"ADR 0274, ADR 0259): who chose, through which channel, with which proofs and which answer are read from the " +
|
|
||||||
"router's own record of that module's ask, never from the caller; recorded once per ask however often it is " +
|
|
||||||
"asked; the caller names the ask and says nothing else. Refused for an ask still open, ended without a choice, " +
|
|
||||||
"or not that module's.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"asker": "the module that asked, e.g. claude-code",
|
|
||||||
"ask": "its ask's id",
|
|
||||||
}, []string{"asker", "ask"})},
|
|
||||||
{Name: "hand-acts", Description: "What was done by hand lately — pushes, plans ended, consumers re-made, acts " +
|
|
||||||
"recorded — who, why and the cause of each, and which causes repeat: each repeat is a healer the mesh lacks.",
|
|
||||||
Input: schema(map[string]string{"days": "how many days back (default 14)"}, nil)},
|
|
||||||
{Name: "durations", Description: "How long things take, as the controller measured them: a send to its machine's " +
|
|
||||||
"report (apply), a plan's tier, a build, and each phase of an " +
|
|
||||||
"ended walk per class (walk-phase, novox/hq ADR 0282) — per machine, repository, module or class/phase, with " +
|
|
||||||
"median, p90 and max. What the core's bounds are set from (novox/hq to-be 45 Phase 0).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"kind": "one kind: apply, plan-tier, build or walk-phase; every kind when absent",
|
|
||||||
"days": "how many days back (default 14)",
|
|
||||||
}, nil)},
|
|
||||||
// What is wrong, and the self-check (novox/hq to-be 45 §2, §4).
|
|
||||||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, " +
|
|
||||||
"then oldest — raised by the watchdogs of the signals table, the self-check's probes and the providers' " +
|
|
||||||
"own words, and cleared when observation says it is resolved, never by hand. Given a key, that one " +
|
|
||||||
"whole with its evidence; with history, every transition lately; with silence, stop one's messages " +
|
|
||||||
"for a while — a hand act, which says why (novox/hq to-be 45 §2).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"key": "a condition's key: that one whole; with history, only its transitions",
|
|
||||||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe or mesh",
|
|
||||||
"severity": "only urgent, or only warning",
|
|
||||||
"machine": "only those about this machine",
|
|
||||||
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
|
||||||
"days": "with history: how many days back (default 7, at most 90)",
|
|
||||||
"silence": "a condition's key: send no message for it for a while; it stays open and in status",
|
|
||||||
"for": "with silence: how long — 30m, 4h, 2d; at most 7d",
|
|
||||||
"why": "with silence: why — required, and recorded in the hand-act log",
|
|
||||||
"cause": "with silence: the cause in a word (the condition's kind when absent)",
|
|
||||||
}, nil, "history")},
|
|
||||||
// What the healers did (novox/hq to-be 45 §7).
|
|
||||||
{Name: "healers", Description: "The healers (novox/hq to-be 45 §7): each registered response to one kind " +
|
|
||||||
"of condition — its repair (the ordinary path again), its budget, what happens when it is spent — every act " +
|
|
||||||
"they took lately with its outcome, and the mesh-wide brake. A heal is never a hand act; whether it " +
|
|
||||||
"repaired anything is the condition's clearing to say. Each act is also in its condition's tried.",
|
|
||||||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
|
||||||
{Name: "doctor", Description: "The self-check (novox/hq to-be 45 §4): the last run's verdict at once — " +
|
|
||||||
"each probe of the design's live invariants passed, failed or could not run, and how long ago. With " +
|
|
||||||
"run, a run now; with probes, the registry; with signals, every row of the signals table and the age " +
|
|
||||||
"of its newest signal. Runs every five minutes on its own; each failure is an open condition.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"run": "\"true\": run every probe now and answer the verdict",
|
|
||||||
"probes": "\"true\": the registry — what each probe asserts, and the condition it raises",
|
|
||||||
"signals": "\"true\": the signals table, each row with the age of its newest signal",
|
|
||||||
"probe": "one probe's id (D14): its verdict alone, with every finding — the verdict shows at most twenty a probe",
|
|
||||||
}, nil, "run", "probes", "signals")},
|
|
||||||
// How a module's new builds reach its machines, and the bus's planned step (novox/hq ADR 0236).
|
|
||||||
{Name: "upgrade", Description: "How each module's new builds reach its machines (novox/hq ADR 0236): rolled " +
|
|
||||||
"out one machine first and judged there at the gate, then the rest — or recorded, waiting for a person's " +
|
|
||||||
"push — with where that comes from (a person, the module, the bus, its irreplaceable data, the default) and " +
|
|
||||||
"why. With module, that one; with policy, a person's choice for it — roll-out, record (with why) or default " +
|
|
||||||
"to take the choice back. The bus is never rolled out.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "one module",
|
|
||||||
"policy": "with module: roll-out, record or default",
|
|
||||||
"together": "\"true\": with roll-out, every machine at once instead of one machine first",
|
|
||||||
"why": "with policy or release-backlog: why — required for record and for a release, kept",
|
|
||||||
"backlog": "\"true\": every build waiting for a gate, per machine — what a release plan would send",
|
|
||||||
"release-backlog": "\"true\": release what waits now, one machine at a time, each judged — after a release " +
|
|
||||||
"plan failed, the mesh waits for this; with why",
|
|
||||||
"cause": "with release-backlog: the cause in a word (optional)",
|
|
||||||
}, nil, "together", "backlog", "release-backlog")},
|
|
||||||
{Name: "bus", Description: "The bus as a planned step (novox/hq to-be 45 §8, ADR 0236): what a bus upgrade " +
|
|
||||||
"would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
|
|
||||||
"With upgrade, start one: a person's act with why, after the streams are snapshotted (snapshot-taken says " +
|
|
||||||
"where, while the mesh takes none itself), saying first whether it can be reverted; bus-maintenance is open " +
|
|
||||||
"while it runs and every stream, consumer and a round trip are checked after.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"upgrade": "\"true\": start the bus's upgrade",
|
|
||||||
"why": "with upgrade: why — required, recorded in the hand-act log",
|
|
||||||
"cause": "with upgrade: the cause in a word (default bus-upgrade)",
|
|
||||||
"reversible": "\"true\": with upgrade, the new version can be undone by putting the old one back",
|
|
||||||
"irreversible": "\"true\": with upgrade, it cannot — your explicit word that it runs anyway",
|
|
||||||
"snapshot-taken": "with upgrade: where the streams' snapshot you took is",
|
|
||||||
}, nil, "upgrade", "reversible", "irreversible")},
|
|
||||||
// A consumer the mesh stopped asking for: retired, waiting for a person, deleted only by one
|
|
||||||
// (novox/hq ADR 0230).
|
|
||||||
{Name: "retire", Description: "A consumer the mesh stops asking for is retired by its provider — access " +
|
|
||||||
"disabled, data kept — after the same answer in five passes; more than three at once, or more than half " +
|
|
||||||
"of those held, waits for a person. With no answer, every provider that waits and what it would retire. " +
|
|
||||||
"With answer approve or reject, a node and a module: retire exactly what that provider waits with, or " +
|
|
||||||
"keep it active — a hand act, which says why (novox/hq ADR 0230).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"answer": "approve or reject: answer what the provider waits with (needs node, module and why)",
|
|
||||||
"node": "with answer: the machine the provider runs on",
|
|
||||||
"module": "with answer: the provider module",
|
|
||||||
"why": "with answer: why — required, and recorded in the hand-act log",
|
|
||||||
"cause": "with answer: the cause in a word (retire-waiting when absent)",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "cleanup", Description: "Every consumer a provider holds retired — its age, its size where the " +
|
|
||||||
"backend knows, and why it was retired — and every module's own data retired on its machine (ADR 0233). " +
|
|
||||||
"With consumer (and node, module): the provider deletes that one retired consumer — never an active one; " +
|
|
||||||
"for a module's own retired item, consumer names the item and the machine's backup holder takes a last " +
|
|
||||||
"restore point of it, then deletes it. With older-than: every retired consumer older than that many " +
|
|
||||||
"days, listed; deleted only with confirm. Deleting is a hand act, which says why (novox/hq ADR 0230).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "with consumer: the machine the provider runs on",
|
|
||||||
"module": "with consumer: the provider module",
|
|
||||||
"consumer": "delete this retired consumer (needs node, module and why)",
|
|
||||||
"older-than": "delete every consumer retired more than this many days (needs why; lists only without confirm)",
|
|
||||||
"confirm": "\"true\": with older-than, delete what is listed",
|
|
||||||
"why": "with consumer or older-than: why — required, and recorded in the hand-act log",
|
|
||||||
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
|
||||||
}, nil, "confirm")},
|
|
||||||
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
|
|
||||||
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
|
|
||||||
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
|
|
||||||
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name",
|
|
||||||
}, []string{"node"})},
|
|
||||||
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
|
||||||
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
|
|
||||||
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
|
|
||||||
"names an account its agents run as, its node-engine judged that account unable to become root within the " +
|
|
||||||
"last 15 minutes, and the login shell's execute is not served there. Anything else, a read that failed " +
|
|
||||||
"included, is not free and says why. The router asks it before an answer from a channel proving its sender " +
|
|
||||||
"may approve. Only reads.",
|
|
||||||
Input: listed(schema(map[string]string{
|
|
||||||
"machines": "the machines to judge, by name: a list, or one text separated by commas",
|
|
||||||
}, []string{"machines"}), "machines")},
|
|
||||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on after handing it over " +
|
|
||||||
"as often as it may, kept in DEAD_LETTERS: whose consumer, the subject, how often it was handed over and " +
|
|
||||||
"when it was given up, newest first. With id: that one whole, with what it said. With deliver: hand it " +
|
|
||||||
"again to the consumer that gave it up, and nobody else; with drop: let it go for good. Delivering and " +
|
|
||||||
"dropping are hand acts, which say why (novox/hq issue 330).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
|
||||||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
|
||||||
"limit": "how many to list, newest first (default 50); only when listing",
|
|
||||||
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
|
||||||
"drop": "a dead letter's id: drop it for good (needs why)",
|
|
||||||
"why": "with deliver or drop: why — required, and recorded in the hand-act log",
|
|
||||||
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
|
||||||
}, nil)},
|
|
||||||
// The data every machine declares (novox/hq ADR 0233).
|
|
||||||
{Name: "data", Description: "Every item of data every machine declares, as the self-check last measured it: " +
|
|
||||||
"its class (irreplaceable, rebuildable, cache), where it is, its size, its newest write, its newest good " +
|
|
||||||
"backup and the bound on it — and what is retired: kept after its module left the machine, removed only by " +
|
|
||||||
"`cleanup delete` (novox/hq ADR 0233).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"machine": "one machine (optional)",
|
|
||||||
"retired": "\"true\": only what is retired",
|
|
||||||
}, nil, "retired")},
|
|
||||||
// Three shapes, as the command has them: one repository, every module standing on a base (on), or
|
|
||||||
// every module behind its source (behind). One per call; a second is refused as passed over.
|
|
||||||
{Name: "build", Description: "Have the build machine build a repository — or, instead, every module that " +
|
|
||||||
"stands on a base (on), or every module behind its source (behind). Answers at once with each build's id: " +
|
|
||||||
"`builds` with an id follows it line by line, and a module is registered when its outcome comes.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name); " +
|
|
||||||
"needed unless on or behind is given",
|
|
||||||
"path": "with repository: the module's directory inside it (optional)",
|
|
||||||
"ref": "with repository: the branch, tag or commit to build (optional)",
|
|
||||||
"on": "instead of a repository: a module's name. Every module the mesh holds that was built against " +
|
|
||||||
"its artifacts is built again from its own source, bases among them asked first — the rebuild a " +
|
|
||||||
"changed base needs, which behind does not see because their sources did not move",
|
|
||||||
"behind": "\"true\", instead of a repository: build every module the mesh holds whose source has moved " +
|
|
||||||
"past the commit it was built from, bases asked first",
|
|
||||||
}, nil, "behind")},
|
|
||||||
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251, to-be 51).
|
|
||||||
{Name: "artifacts", Description: "Every artifact the mesh recorded making that lives in its artifact store, " +
|
|
||||||
"each with its repository, digest, kind (image or archive) and state: kept — and why: a definition names " +
|
|
||||||
"it, or one of the five most recent builds of a module the mesh holds — or eligible: made, kept for no " +
|
|
||||||
"reason, not yet let go. Collected ones are counted, and listed only with collected. Reads only " +
|
|
||||||
"(novox/hq ADR 0251).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
|
||||||
"collected": "\"true\": list the collected references too (they only grow; counted always)",
|
|
||||||
}, nil, "collected")},
|
|
||||||
{Name: "collect", Description: "The artifact store's sweep, now: what the mesh made and keeps for no reason " +
|
|
||||||
"is let go of. Without confirm a dry run — every kept archive asked about with HEADs, what would be let go " +
|
|
||||||
"listed, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible " +
|
|
||||||
"artifact let go of, oldest first, and recorded collected — a hand act, recorded with its why. A confirmed " +
|
|
||||||
"collect lets an index go with the platform manifests no kept index names (read first; any read that " +
|
|
||||||
"fails lets nothing go); the sweep after a build leaves an eligible index for such a collect (novox/hq ADR " +
|
|
||||||
"0257). Bounded by " +
|
|
||||||
"most (500 by default, at most 5000) and 45 seconds; what is left is said. Bytes are reclaimed by the " +
|
|
||||||
"store's nightly collector. Never touches a digest the mesh did not record (novox/hq ADR 0189, ADR 0251).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
|
||||||
"why": "with confirm: why — required, and recorded in the hand-act log",
|
|
||||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
|
||||||
}, nil, "confirm")},
|
|
||||||
{Name: "move-placement", Description: "The move of placement out of the settings layers, run once after the " +
|
|
||||||
"delivery that holds it (novox/hq ADR 0348 clause 11, issue 736): every placement key a settings layer still " +
|
|
||||||
"holds — endpoints, expose, reach and ports, which a layer set before then keeps among its settings — is put " +
|
|
||||||
"beside the assignment, where places, accesses and networks already are. Without confirm it says which layers " +
|
|
||||||
"hold which keys and moves nothing. With confirm and why: in one transaction, each such layer is kept whole " +
|
|
||||||
"in its history first (why: moved to placement), its keys are laid over the placement beside it, and the " +
|
|
||||||
"layer keeps its other keys, when it was set and who set it — and the act is recorded in the hand-act log. " +
|
|
||||||
"Answers each layer moved: its machine (empty for the whole mesh), its module and its keys. Nothing a " +
|
|
||||||
"machine is told changes: composition reads the keys as placement wherever they are. Asked again, it " +
|
|
||||||
"moves nothing.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"confirm": "\"true\": move them (needs why); without it nothing is moved",
|
|
||||||
"why": "with confirm: why — required, and recorded in the hand-act log",
|
|
||||||
}, nil, "confirm")},
|
|
||||||
{Name: "move-config", Description: "The move of config: every config value a settings layer holds is set in " +
|
|
||||||
"mesh-config, run once after the delivery that holds it and after move-placement (novox/hq ADR 0348 §9, " +
|
|
||||||
"issue 664). A machine's layer becomes the module's own value on that machine; a whole-mesh layer becomes " +
|
|
||||||
"mesh config for the predefined key its value falls back to where that module alone falls back to it, and " +
|
|
||||||
"otherwise the module's own value on each machine it is assigned to, laid under that machine's layer; a key " +
|
|
||||||
"a layer says by its file's name is set under its config key. Without confirm it says each value it would " +
|
|
||||||
"set, where from, each layer that stays (its module is in the settings form) and anything that refuses the " +
|
|
||||||
"move, and sets nothing. With confirm and why: each value is set through mesh-config in the controller's " +
|
|
||||||
"name, every module's resolved values are read back and compared with what its layers lay today, and only " +
|
|
||||||
"then, in one transaction, is each layer moved kept whole in its history first (why: moved to mesh-config) " +
|
|
||||||
"and emptied, and the controller switched to read mesh-config where it read the layers — the act recorded " +
|
|
||||||
"in the hand-act log. Nothing a machine is told changes. Asked again, it sets nothing set already.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"confirm": "\"true\": set the values and switch (needs why); without it nothing is set",
|
|
||||||
"why": "with confirm: why — required, and recorded in the hand-act log and with each value",
|
|
||||||
}, nil, "confirm")},
|
|
||||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store for builds to stand on, as " +
|
|
||||||
"the records hold them: each with its state — kept because a kept build stood on it, or eligible — and " +
|
|
||||||
"which build or person recorded it. With record: references of copies no record names (a copy made " +
|
|
||||||
"before copies were recorded, or by a build that failed before then) to record as the mesh's, so the " +
|
|
||||||
"sweep keeps or lets go of them like any other; only a repository the mirror writes is taken " +
|
|
||||||
"(upstream/<host>/<path>, or a module's <module>/on-<argument>), and each must be in the store. Without " +
|
|
||||||
"confirm a dry run; with confirm and why, recorded — a hand act. A recorded copy is eligible: the next " +
|
|
||||||
"sweep lets it go unless a kept build stood on it. Records only: this verb deletes nothing " +
|
|
||||||
"(novox/hq ADR 0257).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"record": "references to record, artifact-store://<repository>@sha256:<hex>, separated by spaces or commas",
|
|
||||||
"confirm": "\"true\": record them (needs why); without it nothing is recorded",
|
|
||||||
"why": "with confirm: why — required, and recorded with each copy and in the hand-act log",
|
|
||||||
}, nil, "confirm")},
|
|
||||||
{Name: "images", Description: "Every container image one machine's declaration names — the declaration the " +
|
|
||||||
"mesh would send it now and the one it was last sent — each with the resources naming it: what the " +
|
|
||||||
"machine keeps when it prunes its images. sent_known is false when the last one sent is not kept with " +
|
|
||||||
"its images. Reads only (novox/hq ADR 0251).",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name",
|
|
||||||
}, []string{"node"})},
|
|
||||||
}
|
|
||||||
|
|
||||||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
|
||||||
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
|
|
||||||
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
|
|
||||||
// enum so a caller sees it and the verb can refuse any other word rather than read it as false.
|
|
||||||
//
|
|
||||||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
|
||||||
// name is refused when the verb is called, never passed over.
|
|
||||||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
|
||||||
isSwitch := map[string]bool{}
|
|
||||||
for _, s := range switches {
|
|
||||||
if _, declared := properties[s]; !declared {
|
|
||||||
panic("a switch that is not a property: " + s)
|
|
||||||
}
|
|
||||||
isSwitch[s] = true
|
|
||||||
}
|
|
||||||
props := map[string]any{}
|
|
||||||
for name, description := range properties {
|
|
||||||
p := map[string]any{"type": "string", "description": description}
|
|
||||||
if isSwitch[name] {
|
|
||||||
p["enum"] = []string{"true", "false"}
|
|
||||||
}
|
|
||||||
props[name] = p
|
|
||||||
}
|
|
||||||
out := map[string]any{"type": "object", "properties": props}
|
|
||||||
if len(required) > 0 {
|
|
||||||
out["required"] = required
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as
|
|
||||||
// any argument, one text separated by commas).
|
|
||||||
func listed(in map[string]any, names ...string) map[string]any {
|
|
||||||
props, _ := in["properties"].(map[string]any)
|
|
||||||
for _, n := range names {
|
|
||||||
p, _ := props[n].(map[string]any)
|
|
||||||
if p == nil {
|
|
||||||
panic("a list that is not a property: " + n)
|
|
||||||
}
|
|
||||||
props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
|
||||||
"description": p["description"]}
|
|
||||||
}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
|
||||||
func unpromised(serves []string, promised []Verb) []string {
|
|
||||||
has := map[string]bool{}
|
|
||||||
for _, v := range promised {
|
|
||||||
has[v.Name] = true
|
|
||||||
}
|
|
||||||
var extra []string
|
|
||||||
for _, s := range serves {
|
|
||||||
if !has[s] {
|
|
||||||
extra = append(extra, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return extra
|
|
||||||
}
|
|
||||||
|
|
||||||
// unservedVerbs is what a seat promises and a claimant's offer for it does not answer.
|
|
||||||
func unservedVerbs(tools []string, promised []Verb) []string {
|
|
||||||
has := map[string]bool{}
|
|
||||||
for _, t := range tools {
|
|
||||||
has[t] = true
|
|
||||||
}
|
|
||||||
var missing []string
|
|
||||||
for _, v := range promised {
|
|
||||||
if !has[v.Name] && !v.Optional {
|
|
||||||
missing = append(missing, v.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user