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
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:
@@ -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
@@ -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" }
|
||||
} }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user