Two different things were both written `requires`. A shell, a display
server and a private network have to be on the machine that needs them.
A database does not — it runs somewhere and is reached over the network.
Both were answered the same way, so requiring a database installed
PostgreSQL on every machine that ran a web application.
What a module provides now carries a scope, the same idea claims already
use, written short in the ordinary case:
"provides": ["shell"]
"provides": [{"name": "database", "scope": "mesh"}]
A mesh-scoped requirement is answered by finding the node already running
it — never by installing it here. Choosing a machine to put a database on
is a decision with consequences, and nothing resolving a web application
should make it silently. With nothing anywhere it refuses and says which
module to assign; with two it refuses and says how to choose.
Choosing is `pin <node> <provision> <from>`, kept per node because that
is the granularity the choice has. A pin at a machine that does not
provide it refuses rather than falling back — a fallback would quietly
move somebody's data. One provider does not overrule a pin either.
Resolving a node now needs to know what the others offer, and working
that out needs them resolved, so it is two passes: the first answers only
what each node offers, the second answers everything. Nothing is ever
declared from the first.
A node's plan says what it takes from elsewhere. It is the only part of a
set that stops working when a different machine goes away, and nothing
else in that output would have said so. It is also where a credential
will hang once there is a mechanism for handing one back.
One test found passing for the wrong reason: it read pins through a join
on the provider, which hides a dangling row whether or not it was cleaned
up. It counts rows now, and bites when the cascade is removed.
518 lines
17 KiB
Go
518 lines
17 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")
|
|
|
|
// Source is where a module comes from and what has been built from it.
|
|
type Source struct {
|
|
Repository string
|
|
Ref string
|
|
// BuiltFrom is the commit the manifest the mesh holds was read at.
|
|
BuiltFrom string
|
|
// Head is the newest commit the source is known to have.
|
|
Head string
|
|
}
|
|
|
|
// Current reports whether what the mesh holds is what the source last had.
|
|
//
|
|
// A module with no source is always current: it was handed over directly, and there is nothing
|
|
// it could be behind. Saying "out of date" about it would be inventing a comparison.
|
|
func (s Source) Current() bool {
|
|
if s.Repository == "" || s.Head == "" {
|
|
return true
|
|
}
|
|
return s.BuiltFrom == s.Head
|
|
}
|
|
|
|
// 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, from Source) error {
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// A module registered without provenance keeps whatever it had. Handing over a manifest by
|
|
// hand is a legitimate way to fix something in a hurry, and it should not silently erase the
|
|
// record of where the module normally comes from — which is the only thing that would say,
|
|
// afterwards, that the machine is running something nobody can rebuild.
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into module (name, manifest, version, source, ref, built_from, source_head)
|
|
values ($1, $2, nullif($3,''), nullif($4,''), nullif($5,''), nullif($6,''), nullif($6,''))
|
|
on conflict (name) do update set
|
|
manifest = excluded.manifest,
|
|
version = excluded.version,
|
|
registered = now(),
|
|
source = coalesce(excluded.source, module.source),
|
|
ref = coalesce(excluded.ref, module.ref),
|
|
built_from = coalesce(excluded.built_from, module.built_from),
|
|
source_head = coalesce(excluded.built_from, module.source_head)`,
|
|
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom)
|
|
return err
|
|
}
|
|
|
|
// SourceMoved records that a module's source has a newer commit than the mesh has built.
|
|
//
|
|
// This is the whole of noticing. Nothing here builds anything — it writes down that the two
|
|
// halves differ, which is what makes *is this current?* answerable without building, and what
|
|
// makes a module that nobody rebuilt visible rather than silent.
|
|
func (i *Inventory) SourceMoved(ctx context.Context, module, head string) error {
|
|
tag, err := i.store.Pool().Exec(ctx,
|
|
`update module set source_head = $2, source_seen = now() where name = $1`, module, head)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SourceOf is where a module came from and whether the mesh is behind it.
|
|
func (i *Inventory) SourceOf(ctx context.Context, module string) (Source, error) {
|
|
var s Source
|
|
var repo, ref, built, head *string
|
|
err := i.store.Pool().QueryRow(ctx,
|
|
`select source, ref, built_from, source_head from module where name = $1`,
|
|
module).Scan(&repo, &ref, &built, &head)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Source{}, fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
|
}
|
|
if err != nil {
|
|
return Source{}, err
|
|
}
|
|
for _, pair := range []struct {
|
|
from *string
|
|
to *string
|
|
}{{repo, &s.Repository}, {ref, &s.Ref}, {built, &s.BuiltFrom}, {head, &s.Head}} {
|
|
if pair.from != nil {
|
|
*pair.to = *pair.from
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Behind is every module the mesh has not built from what its source now has, with the nodes
|
|
// running the old one.
|
|
//
|
|
// The nodes are the point. "Is this module out of date" is a fact about the catalogue; "which
|
|
// machines are running last week's version" is the question somebody actually has, and it is the
|
|
// one novox/hq ADR 0010 names as the thing that must not be lost.
|
|
func (i *Inventory) Behind(ctx context.Context) (map[string][]string, error) {
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select m.name, coalesce(n.name, '')
|
|
from module m
|
|
left join assignment a on a.module = m.name
|
|
left join node n on n.id = a.node
|
|
where m.source is not null
|
|
and m.source_head is not null
|
|
and coalesce(m.built_from, '') is distinct from m.source_head
|
|
order by m.name, n.name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string][]string{}
|
|
for rows.Next() {
|
|
var module, node string
|
|
if err := rows.Scan(&module, &node); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, seen := out[module]; !seen {
|
|
out[module] = nil
|
|
}
|
|
if node != "" {
|
|
out[module] = append(out[module], node)
|
|
}
|
|
}
|
|
return out, rows.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()
|
|
}
|
|
|
|
// Provide records a module the control plane ships with itself.
|
|
//
|
|
// The mesh's own private network is one: what computes its files is in this binary, so the
|
|
// manifest is too. Marked so it can be told apart from a module somebody wrote — not because it
|
|
// behaves differently, but because "where did this come from" must have an answer for everything
|
|
// in the catalogue, and "it came with the control plane" is that answer.
|
|
func (i *Inventory) Provide(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, source)
|
|
values ($1, $2, $3, 'the control plane')
|
|
on conflict (name) do update set manifest = excluded.manifest,
|
|
version = excluded.version, source = 'the control plane'`,
|
|
m.Module, raw, m.Version)
|
|
return err
|
|
}
|
|
|
|
// Provided reports whether a module came with the control plane rather than from a repository.
|
|
func (i *Inventory) Provided(ctx context.Context, name string) (bool, error) {
|
|
var source *string
|
|
err := i.store.Pool().QueryRow(ctx,
|
|
`select source from module where name = $1`, name).Scan(&source)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return source != nil && *source == "the control plane", nil
|
|
}
|
|
|
|
// ForgetModule removes a module, unless a machine is running it, and never one the control plane
|
|
// provides.
|
|
func (i *Inventory) ForgetModule(ctx context.Context, name string) error {
|
|
provided, err := i.Provided(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if provided {
|
|
// Refused rather than removed and re-created on the next migrate, which would look like
|
|
// it worked and quietly come back. Taking it off a machine is what "I do not want this"
|
|
// means, and that is what unassign is for.
|
|
return fmt.Errorf(
|
|
"%s comes with the control plane and cannot be forgotten; unassign it instead", name)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// SetSettings records what somebody wants a module's configuration to say.
|
|
//
|
|
// An empty node name means the whole mesh. Replacing rather than merging what is already there:
|
|
// this is a statement of the whole layer, so removing a key is done by leaving it out, which is
|
|
// the only way removing one could work at all.
|
|
func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if nodeName == "" {
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into settings (node, module, values) values (null, $1, $2)
|
|
on conflict (module) where node is null
|
|
do update set values = excluded.values, set_at = now()`, module, raw)
|
|
return wrapModule(err, module)
|
|
}
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into settings (node, module, values) values ($1, $2, $3)
|
|
on conflict (node, module) where node is not null
|
|
do update set values = excluded.values, set_at = now()`, node.ID, module, raw)
|
|
return wrapModule(err, module)
|
|
}
|
|
|
|
func wrapModule(err error, module string) error {
|
|
if err != nil && strings.Contains(err.Error(), "settings_module_fkey") {
|
|
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ClearSettings removes a layer.
|
|
func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string) error {
|
|
if nodeName == "" {
|
|
_, err := i.store.Pool().Exec(ctx,
|
|
`delete from settings where module = $1 and node is null`, module)
|
|
return err
|
|
}
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`delete from settings where module = $1 and node = $2`, module, node.ID)
|
|
return err
|
|
}
|
|
|
|
// SettingsFor is the layers that apply to one module on one node, in the order they are applied.
|
|
//
|
|
// The mesh's first, then the node's, so a node that differs is expressed by differing rather
|
|
// than by restating everything the rest of the mesh already says.
|
|
func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([]catalogue.Layer, error) {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select node is null, values from settings
|
|
where module = $1 and (node is null or node = $2)
|
|
order by node is null desc`, module, node.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var layers []catalogue.Layer
|
|
for rows.Next() {
|
|
var meshWide bool
|
|
var raw []byte
|
|
if err := rows.Scan(&meshWide, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
values := map[string]any{}
|
|
if err := json.Unmarshal(raw, &values); err != nil {
|
|
return nil, err
|
|
}
|
|
from := nodeName
|
|
if meshWide {
|
|
from = "the mesh"
|
|
}
|
|
layers = append(layers, catalogue.Layer{From: from, Values: values})
|
|
}
|
|
return layers, rows.Err()
|
|
}
|
|
|
|
// PinProvision records which node a machine gets a provision from.
|
|
//
|
|
// Only needed when more than one node could answer. Recordable before that, because a mesh with
|
|
// one database should not change where an existing machine gets its data the day a second
|
|
// arrives.
|
|
func (i *Inventory) PinProvision(ctx context.Context, nodeName, provision, provider string) error {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
from, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into provision_pin (node, name, provider) values ($1, $2, $3)
|
|
on conflict (node, name) do update set provider = excluded.provider, pinned_at = now()`,
|
|
node.ID, provision, from.ID)
|
|
return err
|
|
}
|
|
|
|
// UnpinProvision removes a choice, putting the question back.
|
|
func (i *Inventory) UnpinProvision(ctx context.Context, nodeName, provision string) error {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tag, err := i.store.Pool().Exec(ctx,
|
|
`delete from provision_pin where node = $1 and name = $2`, node.ID, provision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("%s was not told where to get %q from", nodeName, provision)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PinsFor is what a node was told about where its provisions come from.
|
|
func (i *Inventory) PinsFor(ctx context.Context, nodeName string) (map[string]string, error) {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select p.name, n.name from provision_pin p join node n on n.id = p.provider
|
|
where p.node = $1`, node.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]string{}
|
|
for rows.Next() {
|
|
var name, provider string
|
|
if err := rows.Scan(&name, &provider); err != nil {
|
|
return nil, err
|
|
}
|
|
out[name] = provider
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// pinRows is how many pins a node holds, counted in the table rather than through the join that
|
|
// reads them.
|
|
//
|
|
// For a test that would otherwise pass for the wrong reason: PinsFor joins on the provider, so a
|
|
// pin left behind by a departed node is invisible through it whether it was cleaned up or not.
|
|
func (i *Inventory) pinRows(ctx context.Context, nodeName string) (int, error) {
|
|
node, err := i.NodeByName(ctx, nodeName)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var n int
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select count(*) from provision_pin where node = $1`, node.ID).Scan(&n)
|
|
return n, err
|
|
}
|