Merge pull request 'A module declares the data it holds; protection and D13 derived from it (hq ADR 0233)' (#88) from feat/a-module-declares-the-data-it-holds into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on

This commit was merged in pull request #88.
This commit is contained in:
2026-10-06 15:00:53 +00:00
26 changed files with 3094 additions and 130 deletions
+5 -1
View File
@@ -133,7 +133,11 @@ type SeatVerb struct{ Seat, Verb string }
// to the probe registry is the reason they cannot drift** — a probe that calls a verb its grant does
// not name is refused by the bus on every run (found live on 2026-10-06: D8 timed out on each
// machine, refused). Asked of any machine (`.*`), read-only verbs, nothing else of the seat.
var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb: "banned"}}
//
// D13 reads every machine's backup holder: what it measured of the data declared there (novox/hq ADR
// 0233).
var VerbsTheSelfCheckAsks = []SeatVerb{{Seat: "node-intrusion-prevention", Verb: "banned"},
{Seat: "node-backup", Verb: "backed-up"}}
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
+1 -1
View File
@@ -24,7 +24,7 @@ accounts {
jetstream: enabled
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*"] }
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
allow_responses: { max: 1, ttl: "1m" }
} }
+8 -33
View File
@@ -5,35 +5,10 @@ import (
"testing"
)
// A store provider says how its data is backed up (novox/hq ADR 0214); a provider of something that
// holds nothing does not have to.
func TestAStoreProviderWithoutABackupIsRefusedByTheCheck(t *testing.T) {
bare, err := ParseManifest([]byte(`{"module":"pg","version":"1",
"provides":[{"name":"postgres-database","scope":"mesh"}]}`))
if err != nil {
t.Fatalf("parsing refused it, and the rule is the check's: %v", err)
}
if problems := CheckBackup(bare); len(problems) != 1 || !strings.Contains(problems[0], "node-backup") {
t.Fatalf("a store with no backup passed the check: %v", problems)
}
backed, err := ParseManifest([]byte(`{"module":"pg","version":"1",
"provides":[{"name":"postgres-database","scope":"mesh"}],
"resources":[{"id":"dumps","type":"directory","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:dumps}"}]}`))
if err != nil {
t.Fatal(err)
}
if problems := CheckBackup(backed); len(problems) != 0 {
t.Fatalf("a store that contributes a backup was refused: %v", problems)
}
route, _ := ParseManifest([]byte(`{"module":"r","version":"1","provides":[{"name":"route","scope":"mesh"}]}`))
if problems := CheckBackup(route); len(problems) != 0 {
t.Fatalf("a route was asked for a backup: %v", problems)
}
}
// A backup contribution names its module's own directories; one it does not declare is refused
// where it is written rather than reaching the holder as the literal text.
// A backup contribution written by hand still names its module's own directories; one it does not
// declare is refused where it is written rather than reaching the holder as the literal text. (The
// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one,
// because a module on the shelf was written before.)
func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"pg","version":"1",
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`))
@@ -42,9 +17,9 @@ func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
}
}
// The holder receives every module's backup lines with each module's own directories filled, each
// module's under a comment naming it — and a kind written in a shell's grammar is left untouched.
func TestBackupContributionsArePlacedWithTheirModulesDirectories(t *testing.T) {
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
pg, err := ParseManifest([]byte(`{"module":"pg","version":"1",
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
@@ -57,7 +32,7 @@ func TestBackupContributionsArePlacedWithTheirModulesDirectories(t *testing.T) {
if err != nil {
t.Fatal(err)
}
placed, err := seatContributions([]Manifest{pg, mail}, BackupSeat, "backup", Rendering{})
placed, err := seatContributions([]Manifest{pg, mail}, BackupSeat, "backup", Rendering{}, nil)
if err != nil {
t.Fatal(err)
}
+15 -14
View File
@@ -87,15 +87,18 @@ func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
}
}
// TestEveryCatalogueStoreSaysHowItIsBackedUp is ADR 0214's check over the real catalogue: a module
// providing a store contributes a backup to node-backup, so a store added is a store backed up.
func TestEveryCatalogueStoreSaysHowItIsBackedUp(t *testing.T) {
// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR
// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a
// backup line by hand, every directory a container writes is declared, and every irreplaceable item is
// backed up — so a store added is a store backed up, without a list of stores to keep in step.
func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) {
root := catalogueRoot(t)
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil || len(found) == 0 {
t.Fatalf("no manifests under %s: %v", root, err)
}
stores := 0
shelf := Shelf{}
granting := 0
for _, p := range found {
raw, err := os.ReadFile(p)
if err != nil {
@@ -105,18 +108,16 @@ func TestEveryCatalogueStoreSaysHowItIsBackedUp(t *testing.T) {
if err != nil {
continue // TestEveryCatalogueManifestParses says why
}
for _, o := range m.Provides {
if storeProvisions[o.Name] {
stores++
break
}
}
for _, problem := range CheckBackup(m) {
t.Error(problem)
if len(m.Grants) > 0 {
granting++
}
shelf[m.Module] = m
}
if stores == 0 {
t.Fatal("no module in the catalogue provides a store, so this proved nothing")
for _, problem := range DataProblems(shelf) {
t.Error(problem)
}
if granting == 0 {
t.Fatal("no module in the catalogue grants a provision, so this proved nothing")
}
}
+791
View File
@@ -0,0 +1,791 @@
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", 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
}
+227
View File
@@ -0,0 +1,227 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
// A store as a module now declares it (novox/hq ADR 0233): its own data by directory with a class and a
// dump, and what it keeps for its consumers.
const declaredStore = `{"module":"pg","version":"1",
"provides":[{"name":"postgres-database","scope":"mesh"}],
"grants":{"postgres-database":"${dir:grants}"},
"data":{
"own":[
{"id":"store","path":"${dir:store}","class":"irreplaceable","backup":{"dump":"dump-all > ${dir:dumps}/all","into":"dumps"},"active":"1d"},
{"id":"dumps","path":"${dir:dumps}","class":"rebuildable","backup":"none"}],
"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}},
"resources":[
{"id":"grants","type":"directory","mode":"0700"},
{"id":"store","type":"directory","path":"/srv/store","mode":"0700"},
{"id":"dumps","type":"directory","path":"${dir:store}/dumps","mode":"0700"},
{"id":"server","type":"container","image":"x@sha256:aa","volumes":["${dir:store}:/var/lib/postgresql/data"]}]}`
func mustParse(t *testing.T, raw string) Manifest {
t.Helper()
m, err := ParseManifest([]byte(raw))
if err != nil {
t.Fatalf("refused: %v", err)
}
return m
}
func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
m := mustParse(t, declaredStore)
if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 {
t.Fatalf("a store declaring its data was refused: %v", problems)
}
if !m.KeepsConsumerData("postgres-database") {
t.Fatal("an irreplaceable consumer class does not keep the consumer's data")
}
if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 {
t.Fatalf("the store item reads %+v", it)
}
if it, _ := m.DataItem("dumps"); it.BackedUp() {
t.Fatal("a rebuildable item that says none is backed up")
}
}
// Every rule of the section itself, refused at parse in the words of the module.
func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) {
for _, c := range []struct{ name, data, want string }{
{"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"},
{"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"},
{"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"},
{"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"},
{"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"},
{"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"},
{"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"},
{"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"},
{"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"},
{"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"},
{"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"},
{"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"},
{"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"},
{"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"},
{"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"},
{"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"},
{"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"},
{"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"},
{"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"},
} {
raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}`
_, err := ParseManifest([]byte(raw))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", c.name, err, c.want)
}
}
}
// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup
// line by hand, and every directory a container writes is declared (novox/hq ADR 0233).
func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) {
unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}],
"grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`)
onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`)
byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`)
writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"},
{"id":"c","type":"directory","mode":"0700"},
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`)
problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n")
for _, want := range []string{
"q grants queue and does not say what it keeps",
"r says keeps-consumer-data on its offer",
"h's contribution 1 is a backup line written by hand",
`w mounts its directory "d" into a container to be written`,
} {
if !strings.Contains(problems, want) {
t.Errorf("the check does not say %q:\n%s", want, problems)
}
}
if strings.Contains(problems, `"c"`) {
t.Errorf("a read-only mount was taken for written data:\n%s", problems)
}
// The same module declaring the directory, as a cache, passes.
declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]},
"resources":[{"id":"d","type":"directory","mode":"0700"},
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`)
if p := DataProblems(Shelf{"w": declared}); len(p) > 0 {
t.Fatalf("a declared directory is still refused: %v", p)
}
}
// Consumer data said to live in a provision the module requires is protected only as well as that
// provision's provider protects its consumers' data.
func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) {
idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"],
"provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"},
"resources":[{"id":"g","type":"directory","mode":"0700"}],
"data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`)
cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`,
`"consumers":{"postgres-database":{"class":"cache"}}`, 1))
if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") {
t.Fatalf("irreplaceable data kept in a cache passed: %s", p)
}
if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 {
t.Fatalf("refused against a provider that keeps its consumers' data: %v", p)
}
}
// What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers
// move freely, a store's do not; an older definition saying nothing still follows its grant.
func TestKeepingConsumerDataFollowsTheClass(t *testing.T) {
for class, keeps := range map[string]bool{"irreplaceable": true, "valuable": true, "rebuildable": true, "cache": false, "none": false} {
in := `,"in":"d"`
own := `"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable"}],`
if !keeps {
in, own = "", ""
}
m := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"},
"resources":[{"id":"d","type":"directory","mode":"0700"}],
"data":{`+own+`"consumers":{"q":{"class":"`+class+`"`+in+`}}}}`)
if m.KeepsConsumerData("q") != keeps {
t.Errorf("class %s keeps consumer data: %v, want %v", class, !keeps, keeps)
}
shelf := map[string]Manifest{"p": m}
if KeepsConsumerData(shelf, "q") != keeps {
t.Errorf("class %s: the provision by name keeps: %v", class, !keeps)
}
}
older := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"},
"resources":[{"id":"d","type":"directory","mode":"0700"}]}`)
if !older.KeepsConsumerData("q") {
t.Fatal("an older definition that grants no longer keeps its consumers' data")
}
}
// The backup holder's lines are derived: the dump runs and the directory it writes into is kept,
// anything but a cache is copied by default — the standard nightly plan — an item on redundancy is not
// copied, and every item is listed with its class and protection for the holder to measure and watch:
// directories filled, a home directory filled from the machine, an operator's path from where the
// assignment placed it. A backup line still written by hand beside a data section is not placed.
func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
pg := mustParse(t, declaredStore)
pg.Contributions = append(pg.Contributions, SeatContribution{Seat: BackupSeat, Kind: "backup", Content: "path /stale"})
agent := mustParse(t, `{"module":"agent","version":"1",
"data":{"own":[{"id":"home","path":"${dir:home}","class":"valuable"},{"id":"tmp","path":"${dir:home}/tmp","class":"cache"}]},
"resources":[{"id":"home","type":"directory","path":"${machine:account-home}/.agent","mode":"0700"}]}`)
media := mustParse(t, `{"module":"media","version":"1",
"accesses":[{"id":"films","mode":"read"}],
"data":{"own":[{"id":"films","path":"${access:films}","class":"irreplaceable","redundancy":"on a redundant array; no room for a copy","measure":"dataset"},
{"id":"meta","path":"${dir:meta}","class":"rebuildable"}]},
"resources":[{"id":"meta","type":"directory","mode":"0700"}]}`)
facts := map[string]string{"account-home": "/home/op"}
with := Rendering{Settings: SettingsBy{"media": {{From: "node", Values: map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}}}}}}
backup, err := seatContributions([]Manifest{pg, agent, media}, BackupSeat, BackupKindBackup, with, facts)
if err != nil {
t.Fatal(err)
}
want := "# agent\npath /home/op/.agent\n# media\npath /var/lib/media/meta\n# pg\nrun dump-all > /srv/store/dumps/all\npath /srv/store/dumps\n"
if backup != want {
t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want)
}
data, err := seatContributions([]Manifest{pg, agent, media}, BackupSeat, BackupKindData, with, facts)
if err != nil {
t.Fatal(err)
}
want = "# agent\nitem home valuable /home/op/.agent /home/op/.agent backup walk\nitem tmp cache /home/op/.agent/tmp - none walk\n" +
"# media\nitem films irreplaceable /tank/films - redundancy dataset\nitem meta rebuildable /var/lib/media/meta /var/lib/media/meta backup walk\n" +
"# pg\nitem store irreplaceable /srv/store /srv/store/dumps backup walk\nitem dumps rebuildable /srv/store/dumps - none walk\n"
if data != want {
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
}
if _, err := seatContributions([]Manifest{agent}, BackupSeat, BackupKindData, Rendering{}, nil); err == nil {
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
}
// What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
// watches its array; valuable data alone is backed up where a holder is, and refused nowhere.
if !slices.Contains(DependsOn(media), BackupSeat) || !slices.Contains(DependsOn(pg), BackupSeat) ||
slices.Contains(DependsOn(agent), BackupSeat) {
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(media), DependsOn(pg), DependsOn(agent))
}
if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" {
t.Fatalf("an operator's path reads %+v", it)
}
}
// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a
// provider that keeps its consumers' data as a cache, or in an item with no protection.
func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) {
photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"],
"data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`)
store := func(backup string) Manifest {
return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}],
"grants":{"s3-bucket":"${dir:g}"},
"data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}],
"consumers":{"s3-bucket":{"class":"valuable","in":"data"}}},
"resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`)
}
if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 {
t.Fatalf("a provider backing its consumers' data up was refused: %v", p)
}
if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") {
t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p)
}
}
+1 -1
View File
@@ -576,7 +576,7 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest,
found := ofContributed.FindStringSubmatch(placeholder)
first, second, _ := strings.Cut(found[2], ":")
if found[1] == "contribution" {
placed, err := seatContributions(modules, first, second, with)
placed, err := seatContributions(modules, first, second, with, facts)
if err != nil && failed == nil {
failed = err
}
+7 -19
View File
@@ -239,25 +239,6 @@ func (m Manifest) IdentityBoundOf(provision string) IdentityBound {
return IdentityBound{}
}
// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes
// there (novox/hq ADR 0232): whether a consumer bound to it is bound to its data.
//
// **What an offer says, it gets.** Unsaid, it follows from whether the provider grants each consumer
// a credential of its own: a provider that does makes an account for every consumer — a role and its
// database, a key and its bucket, a client — and what the consumer writes under that account stays
// with that provider. One that grants nothing keeps nothing of anybody's: the resolver, a CA, the
// artifact store each answer any consumer alike, and moving a consumer between two of them loses
// nothing.
func (m Manifest) KeepsConsumerData(provision string) bool {
for _, o := range m.Provides {
if o.Name == provision && o.KeepsConsumerData != nil {
return *o.KeepsConsumerData
}
}
_, grants := m.Grants[provision]
return grants
}
// KeepsConsumerData is whether a provision, by name, keeps its consumers' data across the
// catalogue: true when any module providing it at the mesh's scope does. **A property of the name**,
// as brokering is: two providers disagreeing would make the same binding sticky or free depending on
@@ -466,6 +447,12 @@ type Manifest struct {
// (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not.
State []StateDeclaration `json:"state,omitempty"`
// Data is every kind of data this module keeps — its own, by directory, and what it keeps for
// its consumers, by provision — each with a class the mesh protects and watches it by (novox/hq
// ADR 0233). One list: the backup holder's lines, the bindings that do not move, what an
// unassignment retires and what the self-check measures are all derived from it.
Data *Data `json:"data,omitempty"`
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
Reads []string `json:"reads,omitempty"`
@@ -2007,6 +1994,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, m.shellProblems()...)
problems = append(problems, m.contributionPlaceholderProblems()...)
problems = append(problems, m.seatContributionProblems()...)
problems = append(problems, m.dataProblems()...)
for i, r := range m.Resources {
id, _ := r["id"].(string)
+27 -42
View File
@@ -23,46 +23,6 @@ const MessageBusSeat = "node-message-bus"
// BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43).
const BackupSeat = "node-backup"
// storeProvisions are the provisions whose provider keeps its consumers' data (novox/hq ADR 0214):
// a module providing one must say how to back it up, or the data the mesh hands out is the data it
// cannot restore — issue 241's seven databases. A provision that holds nothing worth keeping (a
// route, a cache, a name) is not here; adding one is adding a store.
var storeProvisions = map[string]bool{
"postgres-database": true,
"mssql-database": true,
"mongodb-database": true,
"s3-bucket": true,
"influxdb-api": true,
"secret": true,
}
// CheckBackup is a store provider that contributes no backup (novox/hq ADR 0214, "How it is
// checked").
//
// **The catalogue check's, not parsing's.** A manifest already registered and running was written
// before the rule; refusing it on read would make the controller refuse the very providers whose
// data the rule protects. New definitions meet it in the catalogue check, where they are written.
func CheckBackup(m Manifest) []string {
backs := false
for _, c := range m.Contributions {
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat && c.Kind == "backup" {
backs = true
}
}
if backs {
return nil
}
var problems []string
for _, o := range m.Provides {
if storeProvisions[o.Name] {
problems = append(problems, fmt.Sprintf(
"%s provides %s, which keeps its consumers' data, and contributes no backup to %s; a "+
"store says how its data is copied (novox/hq ADR 0214)", m.Module, o.Name, BackupSeat))
}
}
return problems
}
// 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.
@@ -188,7 +148,11 @@ func seatPlaceholderProblems(m Manifest, r map[string]any) []string {
// 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).
func seatContributions(modules []Manifest, seat, kind string, with Rendering) (string, error) {
//
// 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).
func seatContributions(modules []Manifest, seat, kind string, with Rendering, facts map[string]string) (string, error) {
s, r, ok := receivable(seat, kind)
if !ok {
return "", nil
@@ -197,7 +161,7 @@ func seatContributions(modules []Manifest, seat, kind string, with Rendering) (s
var b strings.Builder
for _, m := range inModuleOrder(modules) {
named := false
for _, c := range m.Contributions {
for _, c := range m.allContributions() {
if c.Kind != kind {
continue
}
@@ -214,6 +178,27 @@ func seatContributions(modules []Manifest, seat, kind string, with Rendering) (s
if err != nil && failed == nil {
failed = 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 && failed == nil {
failed = err
}
}
for _, key := range machineUsed(filled) {
value, has := facts[key]
if !has {
if failed == nil {
failed = 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
}
b.WriteString(content)
+6
View File
@@ -123,6 +123,12 @@ func contributedTo(m Manifest) []string {
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
}
+4 -2
View File
@@ -256,8 +256,10 @@ var defaultSeats = append([]Seat{
// 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(),
Receives: []Receivable{{Kind: "backup", Comment: "#", Dirs: true}}},
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"},
+13 -2
View File
@@ -282,8 +282,10 @@ var ControllerVerbs = []Verb{
"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. With consumer (and node, module): the provider deletes that one " +
"retired consumer — never an active one. With older-than: every retired consumer older than that many " +
"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",
@@ -294,6 +296,15 @@ var ControllerVerbs = []Verb{
"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")},
// 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")},
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
Input: schema(map[string]string{
+316
View File
@@ -0,0 +1,316 @@
package inventory
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The data a module declares, as the mesh found it on each machine (novox/hq ADR 0233).
//
// The self-check composes what every machine declares and asks its backup holder what it measured;
// this keeps both. An irreplaceable item a machine no longer declares — its module unassigned, or no
// longer pulled in — is retired here, not forgotten: kept, with when and why, until a person deletes
// it through `cleanup delete`.
// DeclaredData is one item a machine's composition declares now.
type DeclaredData struct {
Module, Item, Class string
// Owned is whether it is in the module's own directory: only that is the mesh's to retire.
Owned bool
// Protection is backup, redundancy, both ("backup+redundancy") or none.
Protection string
}
// Redundancy is the redundant storage an item is on, as its machine's holder read it.
type Redundancy struct {
Kind string `json:"kind"`
Where string `json:"where"`
Healthy *bool `json:"healthy"`
Said string `json:"said"`
}
// Measurement is what a machine's backup holder said of one item. Nil fields were not measured.
type Measurement struct {
Path string
Size *int64
LastWrite *time.Time
MeasuredAt *time.Time
LastBackup *time.Time
// Error is what the holder could not measure, when it could not.
Error string
// Precision is what the size is, as the holder said it: "exact", "dataset …", "partial: …", "no size: …".
Precision string
Redundancy *Redundancy
}
// DataRecord is one item as the mesh keeps it.
type DataRecord struct {
Machine, Module, Item, Class, Path string
Owned bool
Protection string
FirstSeen, DeclaredAt time.Time
MeasureError string
Precision string
Redundancy *Redundancy
Size *int64
LastWrite, MeasuredAt, LastBackup *time.Time
RetiredAt *time.Time
RetiredWhy string
DeletedAt *time.Time
DeletedBy, DeletedWhy string
}
// Comparable is whether a size is fit to compare with another: exact, or a dataset's own counters.
func Comparable(precision string) bool {
return precision == "" || precision == "exact" || strings.HasPrefix(precision, "dataset ")
}
// Dataset is the ZFS dataset a size was read from, when it was: what several items on one dataset share.
func Dataset(precision string) string {
rest, ok := strings.CutPrefix(precision, "dataset ")
if !ok {
return ""
}
name, _, _ := strings.Cut(rest, ":")
return name
}
// Retired is whether the item is retired and not deleted.
func (r DataRecord) Retired() bool { return r.RetiredAt != nil && r.DeletedAt == nil }
// Key names it in one string, the way conditions and verbs do: <machine>/<module>/<item>.
func (r DataRecord) Key() string { return r.Machine + "/" + r.Module + "/" + r.Item }
// DataChange is what recording a machine's data changed: what it retired and what came back.
type DataChange struct {
Retired, Reenabled []DataRecord
}
// readingEvery is how often a measurement is kept as a reading: the shrink is read over days, and a
// row every five minutes would say the same thing sixty times an hour.
const readingEvery = 55 * time.Minute
// readingsKept is how long readings are kept.
const readingsKept = 90 * 24 * time.Hour
// dataKey is one item's identity on one machine.
type dataKey struct{ module, item string }
// RecordData keeps what one machine declares now and what its holder measured, at now.
//
// **Only from a composition that worked.** The caller passes the declared set of a machine whose
// composition succeeded; an item missing from it is then really no longer declared. An irreplaceable
// one is retired — never deleted, never forgotten — and anything else is forgotten, because what is
// rebuildable or a cache is not the mesh's to keep track of once its module is gone. An item declared
// again comes back from retirement as it was.
func (i *Inventory) RecordData(ctx context.Context, machine string, declared []DeclaredData,
measured map[string]map[string]Measurement, why string, now time.Time) (DataChange, error) {
var change DataChange
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return change, err
}
defer tx.Rollback(ctx) //nolint:errcheck
existing := map[dataKey]DataRecord{}
rows, err := tx.Query(ctx, dataSelect+` where machine = $1`, machine)
if err != nil {
return change, err
}
for rows.Next() {
r, err := scanData(rows)
if err != nil {
rows.Close()
return change, err
}
existing[dataKey{r.Module, r.Item}] = r
}
rows.Close()
if err := rows.Err(); err != nil {
return change, err
}
seen := map[dataKey]bool{}
for _, d := range declared {
k := dataKey{d.Module, d.Item}
seen[k] = true
m := measured[d.Module][d.Item]
var red struct {
Kind, Where, Said *string
Healthy *bool
}
if r := m.Redundancy; r != nil {
red.Kind, red.Where, red.Said, red.Healthy = &r.Kind, &r.Where, &r.Said, r.Healthy
}
was, had := existing[k]
if had && was.Retired() {
change.Reenabled = append(change.Reenabled, was)
}
// Deleted and declared again is new data: it starts over.
fresh := !had || was.DeletedAt != nil
if _, err := tx.Exec(ctx, `
insert into data_item (machine, module, item, class, path, first_seen, declared_at,
size_bytes, last_write, measured_at, last_backup, owned, protection,
measure_error, redundancy_kind, redundancy_where, redundancy_healthy, redundancy_said,
precision)
values ($1, $2, $3, $4, $5, $6, $6, $7, $8, $9, $10, $12, $13, $14, $15, $16, $17, $18, $19)
on conflict (machine, module, item) do update set
class = excluded.class,
owned = excluded.owned,
protection = excluded.protection,
precision = case when excluded.measured_at is not null then excluded.precision else data_item.precision end,
size_bytes = case when excluded.measured_at is not null then excluded.size_bytes else data_item.size_bytes end,
measure_error = case when excluded.measured_at is not null then excluded.measure_error else data_item.measure_error end,
redundancy_kind = case when excluded.measured_at is not null then excluded.redundancy_kind else data_item.redundancy_kind end,
redundancy_where = case when excluded.measured_at is not null then excluded.redundancy_where else data_item.redundancy_where end,
redundancy_healthy = case when excluded.measured_at is not null then excluded.redundancy_healthy else data_item.redundancy_healthy end,
redundancy_said = case when excluded.measured_at is not null then excluded.redundancy_said else data_item.redundancy_said end,
path = case when excluded.path <> '' then excluded.path else data_item.path end,
first_seen = case when $11::boolean then excluded.first_seen else data_item.first_seen end,
declared_at = excluded.declared_at,
last_write = coalesce(excluded.last_write, data_item.last_write),
measured_at = coalesce(excluded.measured_at, data_item.measured_at),
last_backup = coalesce(excluded.last_backup, data_item.last_backup),
retired_at = null, retired_why = null,
deleted_at = null, deleted_by = null, deleted_why = null`,
machine, d.Module, d.Item, d.Class, m.Path, now, m.Size, m.LastWrite, m.MeasuredAt, m.LastBackup,
fresh, d.Owned, d.Protection, nullable(m.Error), red.Kind, red.Where, red.Healthy, red.Said,
nullable(m.Precision)); err != nil {
return change, err
}
if m.Size != nil && m.MeasuredAt != nil && Comparable(m.Precision) {
if _, err := tx.Exec(ctx, `
insert into data_reading (machine, module, item, at, size_bytes, last_write)
select $1, $2, $3, $4, $5, $6
where not exists (select 1 from data_reading
where machine = $1 and module = $2 and item = $3 and at > $4::timestamptz - $7::interval)`,
machine, d.Module, d.Item, *m.MeasuredAt, *m.Size, m.LastWrite,
fmt.Sprintf("%d seconds", int(readingEvery.Seconds()))); err != nil {
return change, err
}
}
}
for k, r := range existing {
if seen[k] || r.DeletedAt != nil || r.Retired() {
continue
}
if !catalogue.Retires(r.Class) || !r.Owned {
if _, err := tx.Exec(ctx, `delete from data_item where machine = $1 and module = $2 and item = $3`,
machine, k.module, k.item); err != nil {
return change, err
}
continue
}
if _, err := tx.Exec(ctx, `update data_item set retired_at = $4, retired_why = $5
where machine = $1 and module = $2 and item = $3`, machine, k.module, k.item, now, why); err != nil {
return change, err
}
at := now
r.RetiredAt, r.RetiredWhy = &at, why
change.Retired = append(change.Retired, r)
}
if _, err := tx.Exec(ctx, `delete from data_reading where at < $1`, now.Add(-readingsKept)); err != nil {
return change, err
}
return change, tx.Commit(ctx)
}
const dataSelect = `select machine, module, item, class, path, first_seen, declared_at, size_bytes, last_write,
measured_at, last_backup, retired_at, coalesce(retired_why, ''), deleted_at, coalesce(deleted_by, ''),
coalesce(deleted_why, ''), owned, protection, coalesce(measure_error, ''), redundancy_kind, redundancy_where,
redundancy_healthy, redundancy_said, coalesce(precision, '') from data_item`
func scanData(rows pgx.Row) (DataRecord, error) {
var r DataRecord
var kind, where, said *string
var healthy *bool
err := rows.Scan(&r.Machine, &r.Module, &r.Item, &r.Class, &r.Path, &r.FirstSeen, &r.DeclaredAt, &r.Size,
&r.LastWrite, &r.MeasuredAt, &r.LastBackup, &r.RetiredAt, &r.RetiredWhy, &r.DeletedAt, &r.DeletedBy,
&r.DeletedWhy, &r.Owned, &r.Protection, &r.MeasureError, &kind, &where, &healthy, &said, &r.Precision)
if err == nil && kind != nil {
r.Redundancy = &Redundancy{Kind: *kind, Healthy: healthy}
if where != nil {
r.Redundancy.Where = *where
}
if said != nil {
r.Redundancy.Said = *said
}
}
return r, err
}
func nullable(s string) *string {
if s == "" {
return nil
}
return &s
}
// Data is every item the mesh keeps, by machine, module and item.
func (i *Inventory) Data(ctx context.Context) ([]DataRecord, error) {
rows, err := i.store.Pool().Query(ctx, dataSelect+` order by machine, module, item`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DataRecord
for rows.Next() {
r, err := scanData(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// DataOf is one item.
func (i *Inventory) DataOf(ctx context.Context, machine, module, item string) (DataRecord, error) {
r, err := scanData(i.store.Pool().QueryRow(ctx, dataSelect+` where machine = $1 and module = $2 and item = $3`,
machine, module, item))
if errors.Is(err, pgx.ErrNoRows) {
return r, fmt.Errorf("the mesh knows no data %s of %s on %s", item, module, machine)
}
return r, err
}
// DataPeaks is each item's largest reading since a moment, keyed by DataRecord.Key.
func (i *Inventory) DataPeaks(ctx context.Context, since time.Time) (map[string]int64, error) {
rows, err := i.store.Pool().Query(ctx, `select machine, module, item, max(size_bytes) from data_reading
where at >= $1 group by machine, module, item`, since)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var machine, module, item string
var peak int64
if err := rows.Scan(&machine, &module, &item, &peak); err != nil {
return nil, err
}
out[machine+"/"+module+"/"+item] = peak
}
return out, rows.Err()
}
// MarkDataDeleted records that a person deleted a retired item. Refused for an item not retired.
func (i *Inventory) MarkDataDeleted(ctx context.Context, machine, module, item, by, why string, at time.Time) error {
tag, err := i.store.Pool().Exec(ctx, `update data_item set deleted_at = $4, deleted_by = $5, deleted_why = $6
where machine = $1 and module = $2 and item = $3 and retired_at is not null and deleted_at is null`,
machine, module, item, at, by, why)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%s of %s on %s is not retired: only retired data is deleted", item, module, machine)
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package inventory
import (
"testing"
"time"
)
func size(n int64) *int64 { return &n }
func at(t time.Time) *time.Time { return &t }
// What a machine declares is kept with what its holder measured; an irreplaceable item it no longer
// declares — its module unassigned — is RETIRED and kept, never forgotten, and comes back as it was when
// declared again; anything else no longer declared is forgotten (novox/hq ADR 0233).
func TestAnUndeclaredIrreplaceableItemIsRetiredNotForgotten(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
declared := []DeclaredData{
{Module: "home-assistant", Item: "config", Class: "irreplaceable", Owned: true},
{Module: "searxng", Item: "valkey", Class: "cache"},
{Module: "ollama", Item: "models", Class: "rebuildable"},
}
measured := map[string]map[string]Measurement{"home-assistant": {"config": {Path: "/var/lib/home-assistant/config",
Size: size(900 << 20), LastWrite: at(now.Add(-time.Minute)), MeasuredAt: at(now), LastBackup: at(now.Add(-6 * time.Hour))}}}
if _, err := inv.RecordData(ctx, "home", declared, measured, "", now); err != nil {
t.Fatal(err)
}
all, err := inv.Data(ctx)
if err != nil || len(all) != 3 {
t.Fatalf("%+v, %v", all, err)
}
// Unassigned: nothing of it is declared any more.
later := now.Add(time.Hour)
change, err := inv.RecordData(ctx, "home", nil, nil, "home-assistant unassigned", later)
if err != nil {
t.Fatal(err)
}
if len(change.Retired) != 1 || change.Retired[0].Item != "config" {
t.Fatalf("retired %+v", change.Retired)
}
all, err = inv.Data(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || !all[0].Retired() || all[0].Path != "/var/lib/home-assistant/config" ||
*all[0].Size != 900<<20 || all[0].RetiredWhy != "home-assistant unassigned" {
t.Fatalf("the irreplaceable item is not kept retired with where it is: %+v", all)
}
// A second run that still does not declare it changes nothing: retired once, not again.
change, err = inv.RecordData(ctx, "home", nil, nil, "again", later.Add(time.Hour))
if err != nil || len(change.Retired) != 0 {
t.Fatalf("retired twice: %+v, %v", change, err)
}
// Deleting needs it retired; assigned again it is not retired any more, and its history stays.
if err := inv.MarkDataDeleted(ctx, "home", "searxng", "valkey", "p", "w", later); err == nil {
t.Fatal("deleting something the mesh does not hold retired was recorded")
}
change, err = inv.RecordData(ctx, "home", declared[:1], nil, "", later.Add(2*time.Hour))
if err != nil || len(change.Reenabled) != 1 {
t.Fatalf("declared again: %+v, %v", change, err)
}
r, err := inv.DataOf(ctx, "home", "home-assistant", "config")
if err != nil || r.Retired() || !r.FirstSeen.Equal(now) || r.Size == nil {
t.Fatalf("declared again lost what was known: %+v, %v", r, err)
}
// Retired and then deleted by a person: recorded, never by dropping the row.
if _, err := inv.RecordData(ctx, "home", nil, nil, "unassigned again", later.Add(3*time.Hour)); err != nil {
t.Fatal(err)
}
if err := inv.MarkDataDeleted(ctx, "home", "home-assistant", "config", "jochen", "moved to the anchor", later.Add(4*time.Hour)); err != nil {
t.Fatal(err)
}
r, err = inv.DataOf(ctx, "home", "home-assistant", "config")
if err != nil || r.DeletedAt == nil || r.DeletedBy != "jochen" || r.Retired() {
t.Fatalf("a deletion is not on record: %+v, %v", r, err)
}
}
// A reading is kept at most once an hour, and the peak is read over the window asked.
func TestReadingsAreHourlyAndThePeakIsTheLargest(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
declared := []DeclaredData{{Module: "grafana", Item: "data", Class: "valuable", Owned: true}}
for i, s := range []int64{100, 120, 999, 130, 40} {
when := now.Add(time.Duration(i) * 20 * time.Minute)
if _, err := inv.RecordData(ctx, "ace", declared, map[string]map[string]Measurement{"grafana": {"data": {
Path: "/var/lib/grafana/data", Size: size(s), MeasuredAt: at(when)}}}, "", when); err != nil {
t.Fatal(err)
}
}
var n int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from data_reading`).Scan(&n); err != nil || n != 2 {
t.Fatalf("%d readings kept for five measurements over 80 minutes, want 2 (one an hour): %v", n, err)
}
peaks, err := inv.DataPeaks(ctx, now.Add(-time.Hour))
if err != nil || peaks["ace/grafana/data"] != 130 {
t.Fatalf("peak %v, %v", peaks, err)
}
r, _ := inv.DataOf(ctx, "ace", "grafana", "data")
if r.Size == nil || *r.Size != 40 {
t.Fatalf("the newest measurement is not the one on record: %+v", r)
}
}
// A partial walk's lower bound is kept as the newest measurement, said as partial, and never kept as a
// reading a shrink would be read against.
func TestAPartialMeasurementIsNeverAReading(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
declared := []DeclaredData{{Module: "big", Item: "data", Class: "valuable", Owned: true}}
if _, err := inv.RecordData(ctx, "home", declared, map[string]map[string]Measurement{"big": {"data": {
Path: "/srv/big", Size: size(5), MeasuredAt: at(now), Precision: "partial: stopped"}}}, "", now); err != nil {
t.Fatal(err)
}
var n int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from data_reading`).Scan(&n); err != nil || n != 0 {
t.Fatalf("%d readings from a partial measurement: %v", n, err)
}
r, err := inv.DataOf(ctx, "home", "big", "data")
if err != nil || r.Precision != "partial: stopped" || Comparable(r.Precision) {
t.Fatalf("%+v, %v", r, err)
}
}
@@ -0,0 +1,68 @@
-- The data a module declares, as the mesh found it on each machine (novox/hq ADR 0233).
--
-- A module's manifest says what data it keeps and of which class. The self-check asks each machine's
-- backup holder what it measured of every declared item — its size, its last write, its last good
-- backup — and keeps that here: so a shrink is read against what the item held before, an item a
-- machine no longer declares is still known to be there, and an empty copy of an item is told from a
-- full one somewhere else.
--
-- **Keyed by the machine's name, not a reference.** The data outlives the machine record, as a
-- binding's provider does (migration 0071): a machine leaving the mesh must not turn the record of
-- what it holds into nothing.
--
-- **Retired, never removed.** An irreplaceable or valuable item in a module's own directory that its
-- machine no longer declares — its module unassigned — is marked retired, with when and why, and stays
-- until a person deletes it through `cleanup delete` (ADR 0230); the deletion is recorded here too,
-- never by dropping the row. An item on an operator's path (an access) is never the mesh's to retire.
--
-- Numbered 0072, past 0071, the highest on main or any open branch when this was written.
create table data_item (
machine text not null,
module text not null,
item text not null,
class text not null,
-- Whether it is in the module's own directory (the mesh's to retire) or an operator's path, and
-- how it is protected: backup, redundancy, both, or none.
owned boolean not null default true,
protection text not null default '',
-- Where it is on the machine, as the backup holder last said; empty until one has.
path text not null default '',
first_seen timestamptz not null default now(),
-- When a composition of the machine last declared it.
declared_at timestamptz not null default now(),
-- The newest measurement: size in bytes, the newest write inside it, and when it was measured.
size_bytes bigint,
last_write timestamptz,
measured_at timestamptz,
-- The newest good backup that covers it.
last_backup timestamptz,
-- What the holder could not measure, when it could not: the path gone, a walk refused.
measure_error text,
-- What the newest size is: exact, a dataset's whole size, partial (a lower bound) or none.
precision text,
-- The redundant storage it is on, as the holder read it: zfs, md or btrfs, which pool or device,
-- whether it is healthy, and what it said.
redundancy_kind text,
redundancy_where text,
redundancy_healthy boolean,
redundancy_said text,
retired_at timestamptz,
retired_why text,
deleted_at timestamptz,
deleted_by text,
deleted_why text,
primary key (machine, module, item)
);
-- One row per measurement kept, at most one an hour per item, for ninety days: what a shrink is
-- read against. Only a comparable size is kept: exact, or a dataset's own counters — never a partial
-- walk's lower bound.
create table data_reading (
machine text not null,
module text not null,
item text not null,
at timestamptz not null,
size_bytes bigint not null,
last_write timestamptz,
primary key (machine, module, item, at)
);
+7 -4
View File
@@ -233,10 +233,13 @@ type RetirementRejected struct {
// RetirementState is a provider's answer to provisioner_retirement.
type RetirementState struct {
Resource string `json:"resource"`
Node string `json:"node"`
Held []string `json:"held"`
StablePasses int `json:"stable_passes"`
Resource string `json:"resource"`
Node string `json:"node"`
Held []string `json:"held"`
// HeldSizes is what each held consumer keeps on the backend, in bytes, where the backend can say
// (novox/hq ADR 0233): what an empty replacement of a consumer's data is told by.
HeldSizes map[string]int64 `json:"held_sizes,omitempty"`
StablePasses int `json:"stable_passes"`
// StableForSeconds is how long the same unasked set must hold as well (ADR 0230: ten minutes).
StableForSeconds int `json:"stable_for_seconds,omitempty"`
// RetiresBy is "disable", or "mark-only" for a provider that cannot disable a consumer.