The gap that has been named at the end of every report for a week. Until now a
declaration came from a person handing over a file; now it comes from what was
assigned, resolved against the catalogue, and the control plane is deciding
rather than relaying.
Everything from the module conversation, built and run on real machines:
assign laptop i3 -> accepted, brings xorg, because nothing else provides
it and there was no choice to make
assign laptop sway -> refused: xorg and wayland both claim the-seat
assign laptop editor -> refused: three modules provide a shell -- bash,
fish, zsh -- choose one
assign laptop zsh -> accepted, and the editor's requirement is answered
bash, fish beside it -> fine, nothing is claimed
Claims rather than pairwise exclusion, so a third display server would say what
it claims and need no edit to xorg or wayland. Scoped to node, site or mesh:
two DHCP servers at one site collide and at two sites do not, and the mesh-wide
one is the hub said as a claim instead of hard-coded.
Some conflicts cost no manifest field at all. The refusal above names the seat
AND the two files, because the mesh already holds every resource of every
module -- neither i3 nor sway knows the other exists.
Resource identities carry their module, so two modules may both call something
"config" without the second silently replacing the first. What a service
reflects is qualified the same way, or it would name a resource that no longer
exists and stop being restarted when its own configuration changes.
Nothing is sent until every node resolves. A push that configured three and
refused on the fourth would leave the mesh in a state nobody asked for, and the
fourth is exactly where a claim collision appears.
One real flaw found by using it rather than by testing it: assigning zsh did
not satisfy a requirement for a shell. Requirements were counted against the
catalogue without first asking what the set already offers, so "choose one and
assign it" named three modules and then ignored the one you chose. The remedy
was useless and every test passed.
196 lines
5.8 KiB
Go
196 lines
5.8 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/novox/mesh-control/internal/catalogue"
|
|
)
|
|
|
|
// ErrNoSuchModule is what the mesh says about a module it has never been told about.
|
|
var ErrNoSuchModule = errors.New("no module of that name")
|
|
|
|
// ErrStillAssigned is why a module cannot be forgotten.
|
|
//
|
|
// Its own error because it is not a fault: it means a machine is running that module now, and
|
|
// removing the record would leave the mesh unable to describe what is on it.
|
|
var ErrStillAssigned = errors.New("that module is still assigned to nodes")
|
|
|
|
// RegisterModule records a module, replacing what was there.
|
|
//
|
|
// Replacing rather than refusing, because a manifest changing is the ordinary case -- a module
|
|
// gains a requirement, a claim, a resource. What matters is that the change is visible the next
|
|
// time a node is resolved, which it is.
|
|
func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest) error {
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into module (name, manifest, version) values ($1, $2, nullif($3,''))
|
|
on conflict (name) do update set manifest = excluded.manifest,
|
|
version = excluded.version,
|
|
registered = now()`,
|
|
m.Module, raw, m.Version)
|
|
return err
|
|
}
|
|
|
|
// Catalogue is every module the mesh knows about, which is what resolution needs: the question
|
|
// "how many modules provide this" cannot be asked of a subset.
|
|
func (i *Inventory) Catalogue(ctx context.Context) (map[string]catalogue.Manifest, error) {
|
|
rows, err := i.store.Pool().Query(ctx, `select manifest from module order by name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]catalogue.Manifest{}
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
var m catalogue.Manifest
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
out[m.Module] = m
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ForgetModule removes a module, unless a machine is running it.
|
|
func (i *Inventory) ForgetModule(ctx context.Context, name string) error {
|
|
var on []string
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select n.name from assignment a join node n on n.id = a.node where a.module = $1
|
|
order by n.name`, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for rows.Next() {
|
|
var node string
|
|
if err := rows.Scan(&node); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
on = append(on, node)
|
|
}
|
|
rows.Close()
|
|
if len(on) > 0 {
|
|
return fmt.Errorf("%w: %s. Unassign it first", ErrStillAssigned, strings.Join(on, ", "))
|
|
}
|
|
|
|
tag, err := i.store.Pool().Exec(ctx, `delete from module where name = $1`, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("%w: %s", ErrNoSuchModule, name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Assign puts a module on a node.
|
|
//
|
|
// Records the intention and checks nothing. Whether the set of assignments can actually become a
|
|
// declaration is resolution's question, asked over the whole set at once — and asking it here,
|
|
// one module at a time, would let an assignment look accepted and then refuse when a second
|
|
// arrives.
|
|
func (i *Inventory) Assign(ctx context.Context, nodeName, module string) error {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into assignment (node, module) values ($1, $2) on conflict do nothing`,
|
|
node.ID, module)
|
|
if err != nil && strings.Contains(err.Error(), "assignment_module_fkey") {
|
|
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Unassign takes a module off a node.
|
|
func (i *Inventory) Unassign(ctx context.Context, nodeName, module string) error {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tag, err := i.store.Pool().Exec(ctx,
|
|
`delete from assignment where node = $1 and module = $2`, node.ID, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("%s is not assigned to %s", module, nodeName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Assigned is what a person put on this node, which is not the same as what it runs: resolution
|
|
// adds whatever those modules require.
|
|
func (i *Inventory) Assigned(ctx context.Context, nodeName string) ([]string, error) {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select module from assignment where node = $1 order by module`, node.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []string
|
|
for rows.Next() {
|
|
var m string
|
|
if err := rows.Scan(&m); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ProfileOf is what a node last said it can do, as resolution needs it: the capabilities that are
|
|
// present, and nothing else.
|
|
func (i *Inventory) ProfileOf(ctx context.Context, nodeName string) (map[string]bool, error) {
|
|
var raw []byte
|
|
err := i.store.Pool().QueryRow(ctx,
|
|
`select profile from node where name = $1`, nodeName).Scan(&raw)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", ErrNoSuchNode, nodeName)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := map[string]bool{}
|
|
if len(raw) == 0 {
|
|
// A node that has never reported. Not an error, and not an empty machine either — every
|
|
// capability will read as absent, so anything requiring one is refused with "the wrong
|
|
// machine", which is wrong but visible. Better than assuming it can do everything.
|
|
return out, nil
|
|
}
|
|
var reported struct {
|
|
Capabilities []struct {
|
|
Name string `json:"name"`
|
|
Present bool `json:"present"`
|
|
} `json:"capabilities"`
|
|
}
|
|
if err := json.Unmarshal(raw, &reported); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range reported.Capabilities {
|
|
if c.Present {
|
|
out[c.Name] = true
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|