// 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"` } // 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 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 }