// Package catalogue is what modules are, and what a node gets when it is assigned some. // // novox/hq ADR 0009: everything is a module, a module declares what it provides and requires, // and a module declares what it claims. This turns a set of assignments into the one declaration // a node is sent — which is the first thing the control plane decides rather than relays. package catalogue import ( "encoding/json" "fmt" "regexp" "sort" "strings" ) // Scopes a claim can have. // // Not everything singular is singular per machine: a seat is one per node, a DHCP server is one // per segment, and the hub is one per mesh. Scope says which, and it is the same idea the mesh // already enforces by hand for the hub. const ( ScopeNode = "node" ScopeSite = "site" ScopeMesh = "mesh" ) // name is what a module, a provision or a claim may be called. // // Constrained because these become resource identities, permission patterns and error messages, // and a name that is valid in one and not the others is a fault found late. var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`) // Claim is a singular resource a module takes over. type Claim struct { Name string `json:"name"` // Scope defaults to the node, which is where nearly everything singular is singular. Scope string `json:"scope,omitempty"` } // At is this claim's scope, with the default applied. func (c Claim) At() string { if c.Scope == "" { return ScopeNode } return c.Scope } // Manifest is everything a module says about itself. type Manifest struct { Module string `json:"module"` Version string `json:"version,omitempty"` // Provides are the names other modules may require. A module always provides its own name; // this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`. Provides []string `json:"provides,omitempty"` // Requires are names that must be provided by something assigned to the same node. Requires []string `json:"requires,omitempty"` // Claims are singular resources. Two modules claiming one thing within a scope cannot both // be assigned there — which is how exclusivity is expressed, rather than as a list of rivals // that every new module would force its predecessors to update. Claims []Claim `json:"claims,omitempty"` // Capabilities the machine must have. A different field from Requires because the remedy // differs: a missing module can be assigned, and a missing capability means the wrong // machine. Capabilities []string `json:"capabilities,omitempty"` // Resources are what this module puts on a node, in the host's own vocabulary. Resources []map[string]any `json:"resources,omitempty"` // Computed names something in the control plane that works this module's resources out per // node, instead of them being fixed here. // // Because some files cannot be written in advance. A machine's peer list on the private // network is derived from every other machine, so it differs on each one and changes when any // of them changes — there is nothing to put in a manifest. // // Being a module anyway is the point: it is assigned like anything else, so a machine that // should not be on the private network simply is not given it, and the network is worked out // over the machines that have it. Before this, connectivity was code beside the module system // doing the same job, and every machine with an address was on the network whether or not // anybody wanted it there. Computed string `json:"computed,omitempty"` // Contributes is what this module tells whatever answers a requirement. // // The other half of an edge. `requires` says a thing must be there; this says what to do with // it — a web application requiring a reverse proxy has to say *which name, which port*, and // until now there was nowhere to put that. Every module that needed it was reduced to // reaching into the control plane's database directly, which is how two of them came to hold // a credential to it permanently. // // Keyed by the requirement, because that is what the contribution is *about*. Contributing to // something is requiring it: asking to be published means a publisher must exist, and a // module that had to say both would eventually say one. Contributes map[string]map[string]any `json:"contributes,omitempty"` // Receives is where this module wants its consumers' contributions written, per requirement // it provides. // // A file, in the mesh's own shape, replaced whenever the set changes. **The control plane // does not know what a reverse proxy is** and does not write one's configuration — it // delivers the facts, and the module turns them into whatever it runs. That boundary is why // swapping the proxy does not touch a single module that publishes through it. Receives map[string]string `json:"receives,omitempty"` } // Wants is everything that must be provided on the same node: what this module requires, and what // it contributes to. func (m Manifest) Wants() []string { out := append([]string{}, m.Requires...) for to := range m.Contributes { var already bool for _, r := range m.Requires { if r == to { already = true } } if !already { out = append(out, to) } } sort.Strings(out) return out } // ReceivedID is the resource identity of the file a provider is given its contributions in. // // Named rather than positional so a module can point `restart-on` at it: a proxy that got a new // route and did not reload is a route that silently does not work, which is the same fault the // overlay had when a peer list changed under a running interface. func ReceivedID(requirement string) string { return "received-" + requirement } // ParseManifest reads a module manifest, refusing anything it cannot act on. // // Every problem is reported rather than the first, because somebody writing a manifest fixes // them in one pass or in four. func ParseManifest(raw []byte) (Manifest, error) { var m Manifest if err := json.Unmarshal(raw, &m); err != nil { return Manifest{}, fmt.Errorf("this is not a module manifest: %w", err) } var problems []string if !name.MatchString(m.Module) { problems = append(problems, fmt.Sprintf( "%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module)) } for _, p := range m.Provides { if !name.MatchString(p) { problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) } if p == m.Module { // Harmless and worth saying: a module always provides its own name, so writing it // suggests the author expected it not to. problems = append(problems, fmt.Sprintf( "%s provides its own name already; listing it says nothing", m.Module)) } } for _, r := range m.Requires { if !name.MatchString(r) { problems = append(problems, fmt.Sprintf("%q is not a usable name to require", r)) } if r == m.Module { problems = append(problems, fmt.Sprintf("%s requires itself", m.Module)) } } for _, c := range m.Claims { if !name.MatchString(c.Name) { problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name)) } switch c.At() { case ScopeNode, ScopeSite, ScopeMesh: default: problems = append(problems, fmt.Sprintf( "%s claims %s at scope %q; a claim is held per node, per site or per mesh", m.Module, c.Name, c.Scope)) } } if m.Computed != "" && len(m.Resources) > 0 { // One or the other. A module that both ships files and has them computed would leave // nobody able to say where a given file came from. problems = append(problems, fmt.Sprintf( "%s has resources of its own and says they are computed by %q; it is one or the other", m.Module, m.Computed)) } for to, values := range m.Contributes { if !name.MatchString(to) { problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) } if len(values) == 0 { // An empty contribution is either a mistake or a requirement written the long way // round, and both are better said plainly. problems = append(problems, fmt.Sprintf( "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) } } for to, where := range m.Receives { if !name.MatchString(to) { problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to)) } if !strings.HasPrefix(where, "/") { problems = append(problems, fmt.Sprintf( "%s receives %q at %q, which is not an absolute path", m.Module, to, where)) } var offered bool for _, o := range m.Offers() { if o == to { offered = true } } if !offered { // Receiving contributions to something you do not provide would create a file nobody // ever writes to, on a machine where nothing asked for it. problems = append(problems, fmt.Sprintf( "%s receives contributions to %q and does not provide it", m.Module, to)) } } for i, r := range m.Resources { id, _ := r["id"].(string) if id == "" { problems = append(problems, fmt.Sprintf("resource %d has no id", i)) } if _, ok := r["type"].(string); !ok { problems = append(problems, fmt.Sprintf("resource %q has no type", id)) } } if len(problems) > 0 { sort.Strings(problems) return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", strings.Join(problems, "\n - ")) } return m, nil } // Offers is everything this module can satisfy: its own name, and what it provides. func (m Manifest) Offers() []string { out := append([]string{m.Module}, m.Provides...) sort.Strings(out) return out }