The keeper process runs under its own account with a bus account of its own, keeps the six states and emits the nine events of ADR 0507 and ADR 0508; its config (upgrade-order, record-days, round-start-minutes) lands in its config file and restarts it. shapes.go fixes every key's value and event body as the interface the keeper and the verbs share. The weekly timer and its service are dropped, which the node-engine removes.
204 lines
6.8 KiB
Go
204 lines
6.8 KiB
Go
package main
|
|
|
|
// The keeper's bus shapes carry only their named fields (novox/hq ADR 0507 §1, AC7; ADR 0508): every key's value
|
|
// and every event's body, filled whole, marshals to exactly the fields named here, and no shape holds a map or a
|
|
// free-form value through which anything else could reach the bus.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// busFields is every shape's JSON fields, by Go type: the interface the keeper and the verbs share.
|
|
var busFields = map[string][]string{
|
|
"InstalledValue": {"at", "packages"},
|
|
"InstalledPackage": {"name", "version", "explicit", "repo"},
|
|
"NotCurrent": {"bytes", "since"},
|
|
"UpdatesValue": {"reachable", "packages"},
|
|
"VersionChange": {"name", "from", "to"},
|
|
"PackageVersion": {"name", "version"},
|
|
"Run": {"node", "run", "kind", "from", "round", "ask", "of", "steps"},
|
|
"Step": {"step", "at", "why", "held", "unit", "ignored", "plan", "exit", "error", "changes", "check"},
|
|
"HeldKernel": {"package", "version", "module", "bound"},
|
|
"Changes": {"added", "removed", "changed"},
|
|
"Check": {"passed", "pacnew", "pacsave", "news", "mapped", "newly_failed", "restart_needed", "running_kernel",
|
|
"installed_kernel", "unbuilt"},
|
|
"NewsTitle": {"title", "link", "published"},
|
|
"Mapped": {"unit", "container", "executable"},
|
|
"DKMSBuild": {"module", "version", "kernel"},
|
|
"RollbackItem": {"name", "from", "to", "source", "location"},
|
|
"Record": {"node", "run", "at", "installed", "kernel", "failed_units", "dkms"},
|
|
"DKMSModule": {"module", "version", "kernels"},
|
|
"Round": {"round", "opened", "opened_by", "order"},
|
|
"RoundNode": {"round", "node", "run", "pass"},
|
|
"Pass": {"reason", "ask", "asked", "answer", "answered"},
|
|
"Ask": {"id", "node", "act", "at", "of", "remove_added", "round", "pass", "reason"},
|
|
"PackageChanged": {"node", "at", "added", "removed", "changed"},
|
|
"UpgradeRecorded": {"node", "run", "packages", "kernel"},
|
|
"KernelHeld": {"node", "run", "held"},
|
|
"UpgradeStarted": {"node", "run", "from", "round", "unit", "ignored"},
|
|
"UpgradeFinished": {"node", "run", "changes"},
|
|
"UpgradeFailed": {"node", "run", "step", "exit", "error"},
|
|
"UpgradeChecked": {"node", "run", "kind", "passed"},
|
|
"RollbackStarted": {"node", "run", "of", "unit"},
|
|
"RollbackFinished": {"node", "run", "of", "exit", "error"},
|
|
}
|
|
|
|
// The value of each state's keys.
|
|
var stateValues = []any{InstalledValue{}, NotCurrent{}, UpdatesValue{}, Run{}, Record{}, Round{}, RoundNode{}, Ask{}}
|
|
|
|
var stamp = time.Date(2026, 10, 24, 4, 0, 12, 0, time.UTC)
|
|
|
|
// fill sets every field of v, recursively, to a value that is not empty: one element in each list, every pointer
|
|
// set, so that every field marshals, omitempty or not.
|
|
func fill(v reflect.Value) {
|
|
switch v.Kind() {
|
|
case reflect.String:
|
|
v.SetString("x")
|
|
case reflect.Bool:
|
|
v.SetBool(true)
|
|
case reflect.Int, reflect.Int64:
|
|
v.SetInt(1)
|
|
case reflect.Pointer:
|
|
v.Set(reflect.New(v.Type().Elem()))
|
|
fill(v.Elem())
|
|
case reflect.Slice:
|
|
s := reflect.MakeSlice(v.Type(), 1, 1)
|
|
fill(s.Index(0))
|
|
v.Set(s)
|
|
case reflect.Struct:
|
|
if v.Type() == reflect.TypeOf(time.Time{}) {
|
|
v.Set(reflect.ValueOf(stamp))
|
|
return
|
|
}
|
|
for i := 0; i < v.NumField(); i++ {
|
|
fill(v.Field(i))
|
|
}
|
|
}
|
|
}
|
|
|
|
// fieldsOf walks a filled value against its marshalled JSON: each struct's object holds exactly its named
|
|
// fields. It answers the struct types it met.
|
|
func fieldsOf(t *testing.T, v reflect.Value, raw json.RawMessage, met map[string]bool) {
|
|
t.Helper()
|
|
switch v.Kind() {
|
|
case reflect.Map, reflect.Interface:
|
|
t.Errorf("%s holds a free-form value: a bus shape names its fields", v.Type())
|
|
case reflect.Pointer:
|
|
fieldsOf(t, v.Elem(), raw, met)
|
|
case reflect.Slice:
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal(raw, &items); err != nil || len(items) != v.Len() {
|
|
t.Errorf("%s: %s is not its list", v.Type(), raw)
|
|
return
|
|
}
|
|
for i := range items {
|
|
fieldsOf(t, v.Index(i), items[i], met)
|
|
}
|
|
case reflect.Struct:
|
|
if v.Type() == reflect.TypeOf(time.Time{}) {
|
|
return
|
|
}
|
|
name := v.Type().Name()
|
|
met[name] = true
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
t.Errorf("%s: %s is not an object", name, raw)
|
|
return
|
|
}
|
|
var got []string
|
|
for k := range obj {
|
|
got = append(got, k)
|
|
}
|
|
want, named := busFields[name]
|
|
if !named {
|
|
t.Errorf("%s is on the bus and its fields are not named in busFields", name)
|
|
return
|
|
}
|
|
want = append([]string(nil), want...)
|
|
sort.Strings(got)
|
|
sort.Strings(want)
|
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
|
t.Errorf("%s marshals to %v, its named fields are %v", name, got, want)
|
|
}
|
|
for i := 0; i < v.NumField(); i++ {
|
|
tag, _, _ := strings.Cut(v.Type().Field(i).Tag.Get("json"), ",")
|
|
if r, ok := obj[tag]; ok {
|
|
fieldsOf(t, v.Field(i), r, met)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryStateValueAndEventBodyCarriesOnlyItsNamedFields(t *testing.T) {
|
|
roots := append([]any(nil), stateValues...)
|
|
for _, e := range Events {
|
|
roots = append(roots, e.Body)
|
|
}
|
|
met := map[string]bool{}
|
|
for _, root := range roots {
|
|
v := reflect.New(reflect.TypeOf(root)).Elem()
|
|
fill(v)
|
|
raw, err := json.Marshal(v.Interface())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fieldsOf(t, v, raw, met)
|
|
}
|
|
for name := range busFields {
|
|
if !met[name] {
|
|
t.Errorf("busFields names %s, which no state value or event body holds", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryEventIsNamedOnceWithItsBody(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for _, e := range Events {
|
|
if seen[e.Name] {
|
|
t.Errorf("%s twice", e.Name)
|
|
}
|
|
seen[e.Name] = true
|
|
if e.Body == nil || reflect.TypeOf(e.Body).Kind() != reflect.Struct {
|
|
t.Errorf("%s has no body type", e.Name)
|
|
}
|
|
}
|
|
if len(Events) != 9 {
|
|
t.Errorf("package.changed and the eight upgrade and rollback events, not %d", len(Events))
|
|
}
|
|
}
|
|
|
|
// A key is node and id tokens joined by dots; each token is one NATS key token.
|
|
func TestKeysAreTokensJoinedByDots(t *testing.T) {
|
|
token := regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
|
run := RunID(time.Date(2026, 10, 24, 6, 0, 12, 0, time.FixedZone("CEST", 2*3600)))
|
|
if run != "20261024T040012Z" {
|
|
t.Errorf("RunID: %s", run)
|
|
}
|
|
round := RoundID(time.Date(2026, 10, 24, 4, 0, 0, 0, time.Local))
|
|
if round != "2026-W43" {
|
|
t.Errorf("RoundID: %s", round)
|
|
}
|
|
if RoundID(time.Date(2027, 1, 2, 4, 0, 0, 0, time.UTC)) != "2026-W53" {
|
|
t.Errorf("the ISO week of 2 January 2027 is 2026's 53rd: %s", RoundID(time.Date(2027, 1, 2, 4, 0, 0, 0, time.UTC)))
|
|
}
|
|
for key, parts := range map[string]int{
|
|
NodeKey("node-a", run): 2, NotCurrentKey("node-a"): 2, RoundNodeKey(round, "node-b"): 2,
|
|
} {
|
|
ts := strings.Split(key, ".")
|
|
if len(ts) != parts {
|
|
t.Errorf("%s: %d tokens", key, len(ts))
|
|
}
|
|
for _, x := range ts {
|
|
if !token.MatchString(x) {
|
|
t.Errorf("%s: token %q", key, x)
|
|
}
|
|
}
|
|
}
|
|
}
|