204 lines
6.9 KiB
Go
204 lines
6.9 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", "news_error", "mapped", "mapped_error", "newly_failed", "restart_needed", "running_kernel",
|
|
"installed_kernel", "unbuilt", "dkms_error"},
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
}
|