Remove what nothing calls once the controller's repository is emptied: facts, the src1 computation, the recreation judge, and the build step's dead helpers (ADR 0593 §11)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -1,25 +1,10 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func raw(v any) (json.RawMessage, error) {
|
||||
if r, ok := v.(json.RawMessage); ok {
|
||||
return r, nil
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
|
||||
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
|
||||
|
||||
|
||||
@@ -147,26 +147,6 @@ func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC4: only the shares change, so the module is not recreated and held for no one; the provider's file changes.
|
||||
func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) {
|
||||
was := mustParse(t, filesWithShares)
|
||||
now := mustParse(t, `{"module":"files","version":"1","requires":["shell",
|
||||
{"name":"s3-bucket","shares":[{"to":"print-and-scan","methods":["get"]}]}]}`)
|
||||
if said := Recreates(was, now).Say("files"); said != "" {
|
||||
t.Errorf("a change of shares recreates the module: %s", said)
|
||||
}
|
||||
if said := Holds(was, now); said != "" {
|
||||
t.Errorf("a change of shares holds the module: %s", said)
|
||||
}
|
||||
before := Resolution{Node: "laptop", Modules: []Manifest{was}}
|
||||
after := Resolution{Node: "laptop", Modules: []Manifest{now}}
|
||||
a, _, _ := before.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
||||
b, _, _ := after.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
||||
if sharesOf(t, a) == sharesOf(t, b) {
|
||||
t.Fatalf("the provider's values did not change: %s", sharesOf(t, a))
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
|
||||
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
|
||||
_, err := decodeForTest([]byte(`{"module":"files","version":"1",
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0377 §3: a move under a module's own `record` rolls out when it recreates none of its containers
|
||||
// and moves none of its data, judged from the two builds' manifests.
|
||||
func TestWhatAMoveUnderRecordHoldsFor(t *testing.T) {
|
||||
base := func() Manifest {
|
||||
raw := `{"module":"mailu","tools":["mailu_status"],
|
||||
"requires":["postgres-database"],
|
||||
"data":{"own":[{"id":"mail","path":"${dir:data-mail}","class":"valuable"}]},
|
||||
"bundles":[{"name":"tools","source":"store/mailu-tools@sha256:aaa","digest":"sha256:aaa"}],
|
||||
"resources":[
|
||||
{"id":"state","type":"directory"},
|
||||
{"id":"mesh-state","type":"directory"},
|
||||
{"id":"data-mail","type":"directory"},
|
||||
{"id":"config-env","type":"file","path":"${dir:state}/mailu.env","content":"A=1"},
|
||||
{"id":"runtime-config","type":"file","path":"${dir:mesh-state}/config.json","content":"{}"},
|
||||
{"id":"inbox-note","type":"file","path":"${dir:data-mail}/README","content":"x"},
|
||||
{"id":"imap","type":"container","image":"registry/imap@sha256:1","volumes":["${dir:data-mail}:/mail"],
|
||||
"env-file":["${dir:state}/mailu.env"]},
|
||||
{"id":"watcher","type":"process","command":"watch"},
|
||||
{"id":"installation","type":"archive","path":"/opt/mailu-tools","artifact":"archive"},
|
||||
{"id":"guard","type":"package","package":"nftables"}]}`
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
resource := func(m *Manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(m *Manifest)
|
||||
holds string // empty: it rolls out
|
||||
}{
|
||||
{"nothing", func(*Manifest) {}, ""},
|
||||
{"a tools bundle", func(m *Manifest) {
|
||||
m.Bundles[0].Digest, m.Bundles[0].Source = "sha256:bbb", "store/mailu-tools@sha256:bbb"
|
||||
}, ""},
|
||||
{"the tool list", func(m *Manifest) { m.Tools = append(m.Tools, "mailu_queue") }, ""},
|
||||
{"a file no container mounts", func(m *Manifest) { resource(m, "runtime-config")["content"] = `{"a":1}` }, ""},
|
||||
{"a container's image", func(m *Manifest) { resource(m, "imap")["image"] = "registry/imap@sha256:2" }, "recreates 1 of mailu's 1"},
|
||||
{"a container's declaration", func(m *Manifest) { resource(m, "imap")["health"] = map[string]any{"kind": "runtime"} }, "recreates 1"},
|
||||
{"a container added", func(m *Manifest) {
|
||||
m.Resources = append(m.Resources, map[string]any{"id": "smtp", "type": "container", "image": "x"})
|
||||
}, "recreates"},
|
||||
{"a mounted file", func(m *Manifest) { resource(m, "config-env")["content"] = "A=2" }, "which its container imap mounts"},
|
||||
{"a file within declared data", func(m *Manifest) { resource(m, "inbox-note")["content"] = "y" }, "which its container imap mounts"},
|
||||
{"a directory holding declared data", func(m *Manifest) { resource(m, "data-mail")["mode"] = "0700" }, "data-mail"},
|
||||
{"the data declaration", func(m *Manifest) { m.Data.Own[0].Class = ClassIrreplaceable }, "data declaration"},
|
||||
{"a provider", func(m *Manifest) { m.Requires = append(m.Requires, "s3") }, "what it requires"},
|
||||
{"what it provides", func(m *Manifest) { m.Provides = append(m.Provides, Offer{Name: "smtp"}) }, "what it provides"},
|
||||
{"a process of a module with data", func(m *Manifest) { resource(m, "watcher")["command"] = "watch2" }, "long-running process watcher"},
|
||||
{"an archive resource", func(m *Manifest) { resource(m, "installation")["artifact"] = "archive2" }, "the archive installation"},
|
||||
{"a package resource", func(m *Manifest) { resource(m, "guard")["package"] = "nftables2" }, "the package guard"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
from, to := base(), base()
|
||||
c.change(&to)
|
||||
got := Holds(from, to)
|
||||
switch {
|
||||
case c.holds == "" && got != "":
|
||||
t.Fatalf("held: %s", got)
|
||||
case c.holds != "" && !strings.Contains(got, c.holds):
|
||||
t.Fatalf("holds %q; want it to say %q", got, c.holds)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A data directory nothing mounts still holds its data.
|
||||
from, to := base(), base()
|
||||
from.Data.Own = append(from.Data.Own, DataItem{ID: "archive", Path: "${dir:mesh-state}/archive", Class: "valuable"})
|
||||
to.Data = &Data{Own: append([]DataItem(nil), from.Data.Own...)}
|
||||
resource(&to, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&from, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&to, "runtime-config")["content"] = "new"
|
||||
if got := Holds(from, to); !strings.Contains(got, "lies within its data archive") {
|
||||
t.Fatalf("a file within data nothing mounts: %q", got)
|
||||
}
|
||||
// A process of a module with no data of its own may change.
|
||||
from, to = base(), base()
|
||||
from.Data, to.Data = nil, nil
|
||||
resource(&to, "watcher")["command"] = "watch2"
|
||||
if got := Holds(from, to); got != "" {
|
||||
t.Fatalf("a process of a module with no data held: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Recreation is what moving a module from one build's manifest to another's does to its containers.
|
||||
type Recreation struct {
|
||||
// Containers is how many containers the new build declares.
|
||||
@@ -19,296 +11,6 @@ type Recreation struct {
|
||||
Images []string
|
||||
}
|
||||
|
||||
// Recreates compares two builds of a module, container by container, by resource id.
|
||||
func Recreates(from, to Manifest) Recreation {
|
||||
before := containersByID(from)
|
||||
after := containersByID(to)
|
||||
var r Recreation
|
||||
r.Containers = len(after)
|
||||
for id, now := range after {
|
||||
was, held := before[id]
|
||||
switch {
|
||||
case !held:
|
||||
r.Recreated = append(r.Recreated, id)
|
||||
r.Images = append(r.Images, id)
|
||||
case !sameDeclaration(was, now):
|
||||
r.Recreated = append(r.Recreated, id)
|
||||
if !sameDeclaration(was["image"], now["image"]) {
|
||||
r.Images = append(r.Images, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range before {
|
||||
if _, kept := after[id]; !kept {
|
||||
r.Recreated = append(r.Recreated, id)
|
||||
r.Images = append(r.Images, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(r.Recreated)
|
||||
sort.Strings(r.Images)
|
||||
return r
|
||||
}
|
||||
|
||||
// SpecOnly is whether the move recreates containers without any new image: a change to how they are
|
||||
// declared only — a health check adopted, a variable — which a person may well not expect to interrupt.
|
||||
func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 }
|
||||
|
||||
// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing.
|
||||
func (r Recreation) Say(module string) string {
|
||||
if len(r.Recreated) == 0 {
|
||||
return ""
|
||||
}
|
||||
what := "with a new image"
|
||||
switch {
|
||||
case r.SpecOnly():
|
||||
what = "no new image, only their declaration"
|
||||
case len(r.Images) < len(r.Recreated):
|
||||
what = fmt.Sprintf("%d with a new image", len(r.Images))
|
||||
}
|
||||
whole := ""
|
||||
if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers {
|
||||
whole = ", every one at once: its service is interrupted until they are up again"
|
||||
} else if len(r.Recreated) > 1 {
|
||||
whole = ", at once: what they serve is interrupted until they are up again"
|
||||
}
|
||||
return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers,
|
||||
what, strings.Join(r.Recreated, ", "), whole)
|
||||
}
|
||||
|
||||
func containersByID(m Manifest) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if t, _ := r["type"].(string); t != "container" {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
out[id] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a
|
||||
// float are one value.
|
||||
func sameDeclaration(a, b any) bool {
|
||||
ra, errA := json.Marshal(a)
|
||||
rb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
var na, nb any
|
||||
_ = json.Unmarshal(ra, &na)
|
||||
_ = json.Unmarshal(rb, &nb)
|
||||
return reflect.DeepEqual(na, nb)
|
||||
}
|
||||
|
||||
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
|
||||
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
|
||||
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
|
||||
// lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides,
|
||||
// requires or contributes; and, for a module that declares data of its own, no change to a long-running process.
|
||||
// Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate
|
||||
// nothing — its tool list — may differ.
|
||||
func Holds(from, to Manifest) string {
|
||||
module := to.Module
|
||||
if module == "" {
|
||||
module = from.Module
|
||||
}
|
||||
if said := Recreates(from, to).Say(module); said != "" {
|
||||
return said
|
||||
}
|
||||
if !sameDeclaration(from.Data, to.Data) {
|
||||
return "it changes " + module + "'s data declaration"
|
||||
}
|
||||
for _, f := range []struct {
|
||||
what string
|
||||
was, s any
|
||||
}{
|
||||
{"what it provides", from.Provides, to.Provides},
|
||||
{"what it requires", from.Requires, to.Requires},
|
||||
{"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires},
|
||||
{"what it contributes to a provider", from.Contributes, to.Contributes},
|
||||
{"what it contributes to a provider", from.ContributesMany, to.ContributesMany},
|
||||
// A setting is filled into the files the module writes: a changed default changes what a container may
|
||||
// mount without the file's own declaration changing.
|
||||
{"its settings, which the files it writes are filled from", from.Settings, to.Settings},
|
||||
} {
|
||||
if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) {
|
||||
return "it changes " + f.what
|
||||
}
|
||||
}
|
||||
mounted := append(mountSources(from), mountSources(to)...)
|
||||
data := append(dataPaths(from), dataPaths(to)...)
|
||||
keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0)
|
||||
before, after := resourcesByID(from), resourcesByID(to)
|
||||
var changed []string
|
||||
for id, r := range after {
|
||||
if was, held := before[id]; !held || !sameDeclaration(was, r) {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
for id := range before {
|
||||
if _, kept := after[id]; !kept {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(changed)
|
||||
dirs := absoluteDirectories(from, to)
|
||||
for _, key := range changed {
|
||||
for _, r := range []map[string]any{before[key], after[key]} {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"])
|
||||
switch kind {
|
||||
case "file", "directory":
|
||||
for _, p := range pathsOf(r, dirs) {
|
||||
for _, m := range mounted {
|
||||
if pathsMeet(p, expandDir(m.source, dirs)) {
|
||||
return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container)
|
||||
}
|
||||
}
|
||||
for _, d := range data {
|
||||
if pathsMeet(p, expandDir(d.path, dirs)) {
|
||||
return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "process", "service":
|
||||
if keepsData {
|
||||
return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id)
|
||||
}
|
||||
default:
|
||||
// Anything that is not a file or directory (judged above by whether a container mounts it
|
||||
// or it holds data) always holds: an archive, a package, a user, a network, or any type
|
||||
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
|
||||
// fields that recreate nothing).
|
||||
return fmt.Sprintf("it changes the %s %s", kind, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type mountSource struct{ container, source string }
|
||||
|
||||
// mountSources is every path a container of the module reads from its machine: its volumes' sources and its
|
||||
// environment files.
|
||||
func mountSources(m Manifest) []mountSource {
|
||||
var out []mountSource
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
id := fmt.Sprint(r["id"])
|
||||
vols, _ := r["volumes"].([]any)
|
||||
for _, v := range vols {
|
||||
s, _ := v.(string)
|
||||
if mount := volumeMount.FindStringSubmatch(s); mount != nil {
|
||||
out = append(out, mountSource{id, mount[1]})
|
||||
}
|
||||
}
|
||||
switch files := r["env-file"].(type) {
|
||||
case string:
|
||||
out = append(out, mountSource{id, files})
|
||||
case []any:
|
||||
for _, f := range files {
|
||||
if s, ok := f.(string); ok {
|
||||
out = append(out, mountSource{id, s})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dataPath struct{ id, path string }
|
||||
|
||||
func dataPaths(m Manifest) []dataPath {
|
||||
if m.Data == nil {
|
||||
return nil
|
||||
}
|
||||
var out []dataPath
|
||||
for _, it := range m.Data.Own {
|
||||
out = append(out, dataPath{it.ID, it.Path})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourcesByID(m Manifest) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// absoluteDirectories is, per directory id, the absolute path a directory of either build states.
|
||||
func absoluteDirectories(ms ...Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range ms {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
continue
|
||||
}
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:<id>}`.
|
||||
func pathsOf(r map[string]any, dirs map[string]string) []string {
|
||||
var out []string
|
||||
if p, ok := r["path"].(string); ok && p != "" {
|
||||
out = append(out, expandDir(p, dirs))
|
||||
}
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expandDir writes a leading `${dir:<id>}` as the directory's absolute path when one is stated, so a path named
|
||||
// either way is one path.
|
||||
func expandDir(p string, dirs map[string]string) string {
|
||||
p = strings.TrimRight(p, "/")
|
||||
if ref := dirPlain.FindStringSubmatch(p); ref != nil {
|
||||
if abs, ok := dirs[ref[1]]; ok {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
if strings.HasPrefix(p, "${dir:") {
|
||||
end := strings.Index(p, "}")
|
||||
if abs, ok := dirs[p[len("${dir:"):end]]; ok {
|
||||
return abs + p[end+1:]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// pathsMeet is whether one path is the other or lies within it.
|
||||
func pathsMeet(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
// emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out.
|
||||
func emptyAsNil(v any) any {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Slice, reflect.Map:
|
||||
if rv.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
case reflect.Ptr:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestHeldKey(t *testing.T) {
|
||||
if err != nil || module != "mesh-mail" || back != fp {
|
||||
t.Errorf("ParseHeldKey = %q, %q, %v", module, back, err)
|
||||
}
|
||||
src1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"})
|
||||
src1 := SourcePrefix + strings.Repeat("1", 64)
|
||||
if _, err := HeldKey("mesh-mail", src1); err == nil {
|
||||
t.Errorf("a src1 fingerprint was held")
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestAssignerValuesRoundTrip(t *testing.T) {
|
||||
|
||||
// 1297/B1a: what a want, a hold and a difference may not say is refused on encode and on decode.
|
||||
func TestAssignerValuesRefused(t *testing.T) {
|
||||
src1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"})
|
||||
src1 := SourcePrefix + strings.Repeat("1", 64)
|
||||
for _, w := range []Want{
|
||||
{},
|
||||
{FP: src1},
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestBuilderBuildKey(t *testing.T) {
|
||||
if err != nil || module != "mesh-mail" || back != fp {
|
||||
t.Errorf("parse = %q, %q, %v", module, back, err)
|
||||
}
|
||||
src1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"})
|
||||
src1 := SourcePrefix + strings.Repeat("1", 64)
|
||||
for module, fp := range map[string]string{"a.b": fp, "": fp, "mesh-mail": src1, "mail ": fp} {
|
||||
if _, err := BuilderBuildKey(module, fp); err == nil {
|
||||
t.Errorf("BuilderBuildKey(%q, %q) accepted", module, fp)
|
||||
|
||||
@@ -51,10 +51,6 @@ func TestBuildRecordDecodes(t *testing.T) { // 1223/AC1
|
||||
if r.SourceInputs == nil {
|
||||
t.Fatal("source-inputs missing")
|
||||
}
|
||||
fp, none := SourceFingerprint(*r.SourceInputs)
|
||||
if none != "" || fp != r.SourceFingerprint {
|
||||
t.Errorf("SourceFingerprint of the decoded inputs = %q (none %q), the record says %q", fp, none, r.SourceFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRecordPath(t *testing.T) { // 1233/AC1
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of
|
||||
// SourceInputs are what the ended controller's source fingerprint (src1) was of, as its build records hold them
|
||||
// (BuildRecord), read and never computed again (novox/hq ADR 0437 §5): the module, the git tree of
|
||||
// its directory, its bases (references, of which only the digest counts), the root tree of each repository its
|
||||
// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build
|
||||
// when it does not (a build that resolves packages when built). A context is the value the builder records for it:
|
||||
@@ -21,43 +15,3 @@ type SourceInputs struct {
|
||||
Toolchains map[string]string
|
||||
Unpinned string
|
||||
}
|
||||
|
||||
// SourceFingerprint is the module's source fingerprint, `src1:` and the hex sha256 of its inputs' lines, or none
|
||||
// and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is empty. It is the
|
||||
// one function the controller's builder and the indexer both call, so they agree byte for byte. The lines are
|
||||
// "module <m>" and "tree <t>", then "base <digest>" (once each), "context <artifact> <tree>" and "toolchain
|
||||
// <artifact> <line>" sorted, all joined by a newline.
|
||||
func SourceFingerprint(in SourceInputs) (fp, none string) {
|
||||
if in.Unpinned != "" {
|
||||
return "", in.Unpinned
|
||||
}
|
||||
if in.Tree == "" {
|
||||
return "", "no tree"
|
||||
}
|
||||
lines := []string{"module " + in.Module, "tree " + in.Tree}
|
||||
bases := map[string]bool{}
|
||||
for _, b := range in.Bases {
|
||||
bases[ReferenceDigest(b)] = true
|
||||
}
|
||||
for b := range bases {
|
||||
lines = append(lines, "base "+b)
|
||||
}
|
||||
for a, t := range in.Contexts {
|
||||
lines = append(lines, "context "+a+" "+t)
|
||||
}
|
||||
for a, t := range in.Toolchains {
|
||||
lines = append(lines, "toolchain "+a+" "+t)
|
||||
}
|
||||
sort.Strings(lines[2:])
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return SourcePrefix + hex.EncodeToString(sum[:]), ""
|
||||
}
|
||||
|
||||
// ReferenceDigest is a reference's digest, `sha256:...`, so the registry address a base was copied into does not
|
||||
// enter the fingerprint; the reference itself when it carries none.
|
||||
func ReferenceDigest(reference string) string {
|
||||
if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" {
|
||||
return digest
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func inputs() SourceInputs {
|
||||
return SourceInputs{
|
||||
Module: "mesh-mail", Tree: "t-module",
|
||||
Bases: []string{"registry.example/base@sha256:aaa", "registry.example/other@sha256:bbb"},
|
||||
Contexts: map[string]string{"mail": "t-root", "tool": "t-root2"},
|
||||
Toolchains: map[string]string{"mail": "go sha256:ccc 0123456789abcdef", "tool": "go sha256:ccc fedcba9876543210"},
|
||||
}
|
||||
}
|
||||
|
||||
func mustFP(t *testing.T, in SourceInputs) string {
|
||||
t.Helper()
|
||||
fp, none := SourceFingerprint(in)
|
||||
if fp == "" || none != "" {
|
||||
t.Fatalf("fingerprint = %q, none = %q", fp, none)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
func TestFingerprintForm(t *testing.T) {
|
||||
fp := mustFP(t, inputs())
|
||||
hex := strings.TrimPrefix(fp, SourcePrefix)
|
||||
if SourcePrefix != "src1:" || hex == fp || len(hex) != 64 || strings.Trim(hex, "0123456789abcdef") != "" {
|
||||
t.Errorf("fingerprint %q is not src1: and 64 hex digits", fp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintOrderAndDuplicates(t *testing.T) { // 1203/AC2
|
||||
want := mustFP(t, inputs())
|
||||
in := inputs()
|
||||
in.Bases = []string{in.Bases[1], in.Bases[0]}
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Errorf("the order of bases changes it: %s", got)
|
||||
}
|
||||
in = inputs()
|
||||
in.Bases = append(in.Bases, in.Bases[0])
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Errorf("a base given twice changes it: %s", got)
|
||||
}
|
||||
in = inputs()
|
||||
in.Bases = append(in.Bases, "other.registry.example/base@sha256:aaa")
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Errorf("two references to one digest change it: %s", got)
|
||||
}
|
||||
// Maps have no order, but build them in many insertion orders anyway.
|
||||
for i := 0; i < 20; i++ {
|
||||
in = inputs()
|
||||
in.Contexts = map[string]string{}
|
||||
in.Toolchains = map[string]string{}
|
||||
for _, k := range []string{"tool", "mail"} {
|
||||
in.Contexts[k] = inputs().Contexts[k]
|
||||
in.Toolchains[k] = inputs().Toolchains[k]
|
||||
}
|
||||
if got := mustFP(t, in); got != want {
|
||||
t.Fatalf("map order changes it: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintChanges(t *testing.T) { // 1203/AC3
|
||||
want := mustFP(t, inputs())
|
||||
for name, f := range map[string]func(*SourceInputs){
|
||||
"module": func(in *SourceInputs) { in.Module = "other" },
|
||||
"tree": func(in *SourceInputs) { in.Tree = "t-other" },
|
||||
"base": func(in *SourceInputs) { in.Bases[0] = "registry.example/base@sha256:zzz" },
|
||||
"a base added": func(in *SourceInputs) { in.Bases = append(in.Bases, "x@sha256:new") },
|
||||
"context": func(in *SourceInputs) { in.Contexts["mail"] = "t-changed" },
|
||||
"context artifact": func(in *SourceInputs) { in.Contexts["extra"] = "t-root" },
|
||||
"toolchain": func(in *SourceInputs) { in.Toolchains["tool"] = "go sha256:ccc changed" },
|
||||
} {
|
||||
in := inputs()
|
||||
f(&in)
|
||||
if got := mustFP(t, in); got == want {
|
||||
t.Errorf("a changed %s keeps the fingerprint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintNone(t *testing.T) { // 1203/AC4
|
||||
in := inputs()
|
||||
in.Unpinned = "the build resolves packages when built"
|
||||
fp, none := SourceFingerprint(in)
|
||||
if fp != "" || none != in.Unpinned {
|
||||
t.Errorf("unpinned: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs()
|
||||
in.Tree = ""
|
||||
fp, none = SourceFingerprint(in)
|
||||
if fp != "" || none != "no tree" {
|
||||
t.Errorf("no tree: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs()
|
||||
in.Tree, in.Unpinned = "", "the build resolves packages when built"
|
||||
if _, none = SourceFingerprint(in); none != in.Unpinned {
|
||||
t.Errorf("the reason for unpinned stands over no tree: %q", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceDigest(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"registry.example/base@sha256:aaa": "sha256:aaa",
|
||||
"registry.example/base": "registry.example/base",
|
||||
"registry.example/base@": "registry.example/base@",
|
||||
"": "",
|
||||
} {
|
||||
if got := ReferenceDigest(in); got != want {
|
||||
t.Errorf("ReferenceDigest(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1203/AC1: three builds the live builder recorded a source fingerprint for. Each value is captured, never written
|
||||
// by hand: the recorded fingerprint was read with mesh-controller.builds (after issues 1204 and 1208 were
|
||||
// delivered); each tree is `git rev-parse <commit>:<dir>` in the repository's clone; the bases are from the
|
||||
// build's `[bases]` log lines; the toolchain lines are mesh-controller's toolchainOf over its toolchains values.
|
||||
func TestFingerprintLiveBuilds(t *testing.T) {
|
||||
const ts = "typescript sha256:4b81682df5665c1f461d3f720a3a0fd7593f4aa71e4fc272dbfdc4bbd235b489 20125bd590041690"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
in SourceInputs
|
||||
recorded string
|
||||
}{
|
||||
{
|
||||
// mailu, build-1792391134620131370: mesh-catalog at b4527d9091e972f28cb365623d9280045e7741a3, dir modules/mailu.
|
||||
name: "mailu",
|
||||
in: SourceInputs{
|
||||
Module: "mailu",
|
||||
Tree: "cfc6fa8ff5fc1e220132e51ade653b9d3f387b3e",
|
||||
Bases: []string{"sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"},
|
||||
Toolchains: map[string]string{"code": ts},
|
||||
},
|
||||
recorded: "src1:539f2e64000d436c572ef53b40e524012455945f8c5a1c6bb173ee0707e88ef4",
|
||||
},
|
||||
{
|
||||
// radarr, build-1792391147149753355: mesh-media-catalog at 197abb061d9b77d67c7f11e9e567528ea7a85436,
|
||||
// dir modules/radarr; no bases.
|
||||
name: "radarr",
|
||||
in: SourceInputs{
|
||||
Module: "radarr",
|
||||
Tree: "42b9c918b5af75cba2bbc75845012e7f64e16df9",
|
||||
Toolchains: map[string]string{
|
||||
"code": ts,
|
||||
"tools-go": "go sha256:2054591ca034b68323d87ec887ba34f56819cb3364034360c0777da4b314dd54 0995d85edc9a6a23",
|
||||
},
|
||||
},
|
||||
recorded: "src1:b3c150d39c395b99dd68974a48490e9a42467aa5d486d0c604d6ef1e8e9c910a",
|
||||
},
|
||||
{
|
||||
// build-agent, build-1792395447088936520: mesh-controller at 7a74cee83f88e6585a5511c3cc53bdf0f5303d20,
|
||||
// dir modules/build-agent; the context is narrowTree's hash of the 185 paths GoBuildSource gave for
|
||||
// cmd/mesh-builder at 7a74cee8.
|
||||
name: "build-agent",
|
||||
in: SourceInputs{
|
||||
Module: "build-agent",
|
||||
Tree: "11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca",
|
||||
Bases: []string{
|
||||
"sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc",
|
||||
"sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c",
|
||||
},
|
||||
Contexts: map[string]string{"server": "narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},
|
||||
},
|
||||
recorded: "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7",
|
||||
},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
fp, none := SourceFingerprint(c.in)
|
||||
if none != "" || fp != c.recorded {
|
||||
t.Errorf("fingerprint = %q (none %q), the live builder recorded %q", fp, none, c.recorded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
// Package facts is the mesh's facts snapshot: what a check needs to judge a change against the mesh
|
||||
// that runs, and nothing it could leak (novox/hq to-be 45 §9, ADR 0227 rule 9).
|
||||
//
|
||||
// **Why it exists.** Every check the mesh had was right about the world it was given, and none was
|
||||
// given the mesh's world: a module passed every test and refused the anchor's whole declaration because
|
||||
// a real machine's name made its identity 23 characters (issue 263); a manifest passed the catalogue
|
||||
// check and was refused by the node-engine (issue 236); a resolver answer that glibc forgave was final
|
||||
// to musl (issue 262). The controller holds those facts. It writes them here, the build seat reads them,
|
||||
// and a merge check composes every machine of the snapshot with the change applied.
|
||||
//
|
||||
// **What it holds, and what it never holds.** Every machine — under a stable pseudonym of the same length
|
||||
// as its name, because the length is what a name limit meets — with its roles, system, C library,
|
||||
// architecture, builds, what it reported it can do, what is assigned there, its pins and settings;
|
||||
// every seat and its holders; every module the mesh holds, as its manifest; the sources the mesh built
|
||||
// them from; the versions of the bus, the store and the node-engine it runs; and how each machine's
|
||||
// declaration composes today. **No secret and no address**: a setting whose key or value reads as a
|
||||
// secret is withheld, an address is replaced by one from a documentation range, and a machine's name, a
|
||||
// site, an account and a public domain are replaced wherever they appear. So a snapshot can be copied
|
||||
// into a test, a replay or a pull request without carrying anything of the installation it came from.
|
||||
//
|
||||
// Copied from mesh-controller internal/facts at 6c2a8d62 (R4b, issue 1380); the one change is each machine's
|
||||
// Grants and Bindings, from format 2.
|
||||
package facts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Format is the snapshot's version. A reader refuses one newer than it knows: a field it cannot read
|
||||
// is a fact it would judge without.
|
||||
//
|
||||
// Format 2 is the first to carry a machine's Grants and Bindings (GrantsFrom): a reader of format 1 refuses a
|
||||
// snapshot of 2 rather than judge a change to a credential or a binding without them, and a reader of 2 reads a
|
||||
// snapshot of 1 as one whose machines have none. MediaType stays as it is: it names the kind of document, and the
|
||||
// format inside it says which fields it carries.
|
||||
const Format = 2
|
||||
|
||||
// GrantsFrom is the first format that carries each machine's Grants and Bindings (novox/hq ADR 0446 section 2): the
|
||||
// credentials made for the consumers of what the machine provides, and where each of its consumers of a provision is
|
||||
// bound. A snapshot of an earlier format says nothing of them, which is not the same as saying there are none.
|
||||
const GrantsFrom = 2
|
||||
|
||||
// Repository and Tag are where the controller keeps the newest snapshot in the artifact store, and
|
||||
// MediaType what it is kept as.
|
||||
const (
|
||||
Repository = "facts"
|
||||
Tag = "latest"
|
||||
MediaType = "application/vnd.novox.mesh.facts.v1+json"
|
||||
)
|
||||
|
||||
// Facts is one snapshot.
|
||||
type Facts struct {
|
||||
Format int `json:"facts"`
|
||||
// Taken is when the controller composed it.
|
||||
Taken time.Time `json:"taken"`
|
||||
// Controller is the controller build that composed it — the one the mesh runs, which is the one a
|
||||
// change to the catalogue must be readable by (version skew).
|
||||
Controller Build `json:"controller"`
|
||||
// Versions are what the mesh runs of the things its tests stand in for.
|
||||
Versions Versions `json:"versions"`
|
||||
// Sources are the repositories the mesh builds modules from, each with the newest commit it built.
|
||||
Sources []Source `json:"sources"`
|
||||
// Machines, in name order of their pseudonyms.
|
||||
Machines []Machine `json:"machines"`
|
||||
// Seats are every seat held, with its holders.
|
||||
Seats []Seat `json:"seats"`
|
||||
// Modules are every module the mesh holds, as it holds them.
|
||||
Modules []Module `json:"modules"`
|
||||
// Settings are the mesh-wide layer of every module's settings, scrubbed.
|
||||
Settings []Settings `json:"settings,omitempty"`
|
||||
// Edges are the build dependencies between modules, as the mesh recorded them — what a merge's
|
||||
// rebuild width is computed from.
|
||||
Edges []Edge `json:"edges,omitempty"`
|
||||
// Beside is the ref each repository is cloned at beside a merge check, by the directory it is found
|
||||
// under — the commits the mesh runs, the catalogue's main — so a check run by hand reads the siblings
|
||||
// the build seat reads (novox/hq issue 286).
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
}
|
||||
|
||||
// Build names one build of a core component.
|
||||
type Build struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
|
||||
// Versions are what the mesh runs.
|
||||
type Versions struct {
|
||||
// Bus is the bus server's release, as the server tells a client that connects.
|
||||
Bus string `json:"bus,omitempty"`
|
||||
// Store is the store's server version, as the store answers it.
|
||||
Store string `json:"store,omitempty"`
|
||||
// NodeEngines are the node-engine builds the machines report, each once.
|
||||
NodeEngines []string `json:"node-engines,omitempty"`
|
||||
// Toolchains are the toolchain images a merge check runs in, by language, as the mesh holds them —
|
||||
// with no address: the artifact store the snapshot is read from is where they are pulled from. What
|
||||
// a repository's merge-check.sh runs in on the build seat, and so what it must run in anywhere else
|
||||
// (novox/hq issue 286): two releases of one compiler disagree, down to how gofmt lays out a file.
|
||||
Toolchains map[string]string `json:"toolchains,omitempty"`
|
||||
}
|
||||
|
||||
// Source is a repository the mesh builds from, and the newest commit it built a module of.
|
||||
type Source struct {
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Modules int `json:"modules"`
|
||||
}
|
||||
|
||||
// Machine is one machine, under its pseudonym.
|
||||
type Machine struct {
|
||||
Name string `json:"name"`
|
||||
// Length is the length of its real name, which the pseudonym keeps.
|
||||
Length int `json:"length"`
|
||||
// Roles are what it is to the mesh, in words: the control node, the hub, the bus's machine, a
|
||||
// holder of a mesh seat. What a check names when it fails one.
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
// System is the node-engine's system (arch, alpine, …), Libc its C library, as far as the mesh knows.
|
||||
System string `json:"system,omitempty"`
|
||||
Libc string `json:"libc,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
// NodeEngine and NodeTools are the builds it runs.
|
||||
NodeEngine string `json:"node-engine,omitempty"`
|
||||
NodeTools string `json:"node-tools,omitempty"`
|
||||
// Contract is the contract of the node-engine's newest health statement (0 when it never stated one): what
|
||||
// says whether the machine takes handed files (novox/hq issue 969). A check that composed it without would
|
||||
// judge a moved module the old way where the mesh hands it.
|
||||
Contract int `json:"contract,omitempty"`
|
||||
// Capabilities are what it reported it can do; the detail kept only where it is a version.
|
||||
Capabilities []Capability `json:"capabilities,omitempty"`
|
||||
// Site, Hub, Public: where it is on the private network — its site (a pseudonym), whether it is the
|
||||
// hub, whether others can dial it. No address.
|
||||
Site string `json:"site,omitempty"`
|
||||
Hub bool `json:"hub,omitempty"`
|
||||
Public bool `json:"public,omitempty"`
|
||||
// OnNetwork is whether it has a place on the private network at all.
|
||||
OnNetwork bool `json:"on-network,omitempty"`
|
||||
// OutwardLinks are the links it reported as facing outside it, scrubbed: a machine that reported none
|
||||
// is sent no filter, so a check that raised it without them would compose a machine the mesh does
|
||||
// not (novox/hq ADR 0140).
|
||||
OutwardLinks []string `json:"outward-links,omitempty"`
|
||||
// Adopted is whether the mesh adopted it rather than converged it.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
// Account is the operator's login there (a pseudonym of the same length), and AccountHome where its
|
||||
// home is when that is not the derived one.
|
||||
Account string `json:"account,omitempty"`
|
||||
AccountHome string `json:"account-home,omitempty"`
|
||||
// AgentAccount is the account agents run as there when it is not the operator's (a pseudonym), and
|
||||
// AgentAccountHome its home when not derived (novox/hq ADR 0266).
|
||||
AgentAccount string `json:"agent-account,omitempty"`
|
||||
AgentAccountHome string `json:"agent-account-home,omitempty"`
|
||||
// PublicDomain is the domain it answers for, its labels replaced.
|
||||
PublicDomain string `json:"public-domain,omitempty"`
|
||||
// Assigned is every module assigned there.
|
||||
Assigned []string `json:"assigned,omitempty"`
|
||||
// Pins are where it was told a provision comes from.
|
||||
Pins []Pin `json:"pins,omitempty"`
|
||||
// Settings are its own layer of each module's settings, scrubbed.
|
||||
Settings []Settings `json:"settings,omitempty"`
|
||||
// Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot
|
||||
// make them, so a check composing this machine gives each a stand-in instead of refusing it.
|
||||
Accepted []Accepted `json:"accepted,omitempty"`
|
||||
// Grants are the pair credentials made for the consumers of what this machine provides (mesh-controller's
|
||||
// inv.SecretsFrom for it): who each is for, never the credential, sealed or not. Written from format GrantsFrom; an
|
||||
// assigner reads them as compose.Input.Issued.
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
// Bindings are where each consumer on this machine was last bound to a provision that keeps data (mesh-controller's
|
||||
// inv.BindingsFor for it), by consumer module and provision. Written from format GrantsFrom; an assigner reads them
|
||||
// as compose.Node.Bound.
|
||||
Bindings []Binding `json:"bindings,omitempty"`
|
||||
// Declaration is how its declaration composes today, as the controller that took this saw it.
|
||||
Declaration Declaration `json:"declaration"`
|
||||
}
|
||||
|
||||
// Capability is one thing a machine reported it can or cannot do.
|
||||
type Capability struct {
|
||||
Name string `json:"name"`
|
||||
Present bool `json:"present"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Pin is one provision a machine was told where to take from.
|
||||
type Pin struct {
|
||||
Provision string `json:"provision"`
|
||||
Machine string `json:"machine"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
|
||||
// Accepted is one secret a person gave: the module's own when Provider is empty, else the credential it
|
||||
// takes from that machine's provider under Local.
|
||||
type Accepted struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// Grant is one pair credential made for a consumer of a provision this machine provides: the provision, the machine and
|
||||
// module that consume it, and the local name the consumer takes it under. The machine is a pseudonym. Nothing of the
|
||||
// credential is here, not even sealed (compose.Issued without its ForProvider).
|
||||
type Grant struct {
|
||||
Provision string `json:"provision"`
|
||||
ConsumerNode string `json:"consumer-node"`
|
||||
ConsumerModule string `json:"consumer-module"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// Binding is where one consumer module on this machine was last bound for a provision: the provider's machine (a
|
||||
// pseudonym) and module (compose.Node.Bound).
|
||||
type Binding struct {
|
||||
ConsumerModule string `json:"consumer-module"`
|
||||
Provision string `json:"provision"`
|
||||
ProviderNode string `json:"provider-node"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
}
|
||||
|
||||
// Settings is one layer of one module's settings.
|
||||
type Settings struct {
|
||||
Module string `json:"module"`
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// Declaration is how one machine's declaration composed when the snapshot was taken.
|
||||
type Declaration struct {
|
||||
// Composes is whether it composed and the node-engine's validator took it.
|
||||
Composes bool `json:"composes"`
|
||||
// Digest is its body's digest — composed as the next push would, so it moves with what the mesh
|
||||
// would send, and is not the digest of what was last sent.
|
||||
Digest string `json:"digest,omitempty"`
|
||||
// Resources are what it declares, as `type:id`, sorted.
|
||||
Resources []string `json:"resources,omitempty"`
|
||||
// Problems are why it does not compose or validate, scrubbed.
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
// LeftOut are the modules assigned there and left out of it, each with why.
|
||||
LeftOut map[string]string `json:"left-out,omitempty"`
|
||||
// Withheld are the consumers its grants leave out because an identity overflows the provision's
|
||||
// bound (ADR 0225), and Unbound the credentials on record for consumers bound elsewhere (issue 274),
|
||||
// each said in words.
|
||||
Withheld []string `json:"withheld,omitempty"`
|
||||
Unbound []string `json:"unbound,omitempty"`
|
||||
}
|
||||
|
||||
// Seat is one seat and the machines holding it.
|
||||
type Seat struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Holders []Holder `json:"holders"`
|
||||
}
|
||||
|
||||
// Holder is one module on one machine holding a seat.
|
||||
type Holder struct {
|
||||
Machine string `json:"machine"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// Module is one module the mesh holds.
|
||||
type Module struct {
|
||||
Name string `json:"name"`
|
||||
// Repository and Path are where it is built from; empty for one that came with the controller.
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
// Commit is the commit the manifest the mesh holds was read at.
|
||||
Commit string `json:"commit,omitempty"`
|
||||
// Provided is a module that came with the controller rather than from a repository.
|
||||
Provided bool `json:"provided,omitempty"`
|
||||
// RollOut is its upgrade policy: rolled out when built, or recorded.
|
||||
RollOut bool `json:"roll-out,omitempty"`
|
||||
// Reads are the other repositories its build read source from.
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
// Sources are the build source its newest build said it read, per repository (novox/hq ADR 0267): a
|
||||
// context of Reads by name with its paths, or the module's own repository (Own). Absent, the module's
|
||||
// build reads every file of each of Reads and of its own directory, as before.
|
||||
Sources []BuildSource `json:"sources,omitempty"`
|
||||
// Manifest is the module as the mesh holds it: artifacts resolved to the builds it runs.
|
||||
Manifest json.RawMessage `json:"manifest"`
|
||||
}
|
||||
|
||||
// BuildSource is the build source a module's build read in one repository (novox/hq ADR 0267).
|
||||
type BuildSource struct {
|
||||
Repository string `json:"repository,omitempty"`
|
||||
Own bool `json:"own,omitempty"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
// Edge is one build dependency: From is built standing on To.
|
||||
type Edge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// Sorted puts every list in a fixed order, so two snapshots of one mesh are the same bytes apart from
|
||||
// when they were taken.
|
||||
func (f *Facts) Sorted() {
|
||||
sort.Slice(f.Machines, func(i, j int) bool { return f.Machines[i].Name < f.Machines[j].Name })
|
||||
for i := range f.Machines {
|
||||
m := &f.Machines[i]
|
||||
sort.Strings(m.Roles)
|
||||
sort.Strings(m.Assigned)
|
||||
sort.Slice(m.Capabilities, func(a, b int) bool { return m.Capabilities[a].Name < m.Capabilities[b].Name })
|
||||
sort.Slice(m.Pins, func(a, b int) bool { return m.Pins[a].Provision < m.Pins[b].Provision })
|
||||
sort.Slice(m.Settings, func(a, b int) bool { return m.Settings[a].Module < m.Settings[b].Module })
|
||||
sort.Slice(m.Grants, func(a, b int) bool {
|
||||
x, y := m.Grants[a], m.Grants[b]
|
||||
return x.Provision+"\x00"+x.ConsumerNode+"\x00"+x.ConsumerModule+"\x00"+x.Local <
|
||||
y.Provision+"\x00"+y.ConsumerNode+"\x00"+y.ConsumerModule+"\x00"+y.Local
|
||||
})
|
||||
sort.Slice(m.Bindings, func(a, b int) bool {
|
||||
x, y := m.Bindings[a], m.Bindings[b]
|
||||
return x.ConsumerModule+"\x00"+x.Provision < y.ConsumerModule+"\x00"+y.Provision
|
||||
})
|
||||
sort.Slice(m.Accepted, func(a, b int) bool {
|
||||
x, y := m.Accepted[a], m.Accepted[b]
|
||||
return x.Module+"\x00"+x.Name+"\x00"+x.Provider+"\x00"+x.Local < y.Module+"\x00"+y.Name+"\x00"+y.Provider+"\x00"+y.Local
|
||||
})
|
||||
sort.Strings(m.Declaration.Resources)
|
||||
}
|
||||
sort.Slice(f.Seats, func(i, j int) bool {
|
||||
if f.Seats[i].Name != f.Seats[j].Name {
|
||||
return f.Seats[i].Name < f.Seats[j].Name
|
||||
}
|
||||
return f.Seats[i].Scope < f.Seats[j].Scope
|
||||
})
|
||||
for i := range f.Seats {
|
||||
h := f.Seats[i].Holders
|
||||
sort.Slice(h, func(a, b int) bool {
|
||||
if h[a].Machine != h[b].Machine {
|
||||
return h[a].Machine < h[b].Machine
|
||||
}
|
||||
return h[a].Module < h[b].Module
|
||||
})
|
||||
}
|
||||
sort.Slice(f.Modules, func(i, j int) bool { return f.Modules[i].Name < f.Modules[j].Name })
|
||||
sort.Slice(f.Sources, func(i, j int) bool { return f.Sources[i].Repository < f.Sources[j].Repository })
|
||||
sort.Slice(f.Settings, func(i, j int) bool { return f.Settings[i].Module < f.Settings[j].Module })
|
||||
sort.Slice(f.Edges, func(i, j int) bool {
|
||||
if f.Edges[i].From != f.Edges[j].From {
|
||||
return f.Edges[i].From < f.Edges[j].From
|
||||
}
|
||||
return f.Edges[i].To < f.Edges[j].To
|
||||
})
|
||||
sort.Strings(f.Versions.NodeEngines)
|
||||
}
|
||||
|
||||
// Encode is the snapshot as it is kept: sorted, indented, so a person can read the one the build seat
|
||||
// read and a diff of two says what moved.
|
||||
func (f Facts) Encode() ([]byte, error) {
|
||||
f.Sorted()
|
||||
return json.MarshalIndent(f, "", " ")
|
||||
}
|
||||
|
||||
// Content is the digest of everything but when it was taken: two snapshots of an unchanged mesh have
|
||||
// the same content, which is how the controller tells a change from another day.
|
||||
func (f Facts) Content() (string, error) {
|
||||
f.Taken = time.Time{}
|
||||
body, err := f.Encode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
return fmt.Sprintf("sha256:%x", sum), nil
|
||||
}
|
||||
|
||||
// Decode reads a snapshot, refusing one newer than this reader knows and one that is not a snapshot.
|
||||
func Decode(body []byte) (Facts, error) {
|
||||
var f Facts
|
||||
if err := json.Unmarshal(body, &f); err != nil {
|
||||
return Facts{}, fmt.Errorf("not a facts snapshot: %w", err)
|
||||
}
|
||||
switch {
|
||||
case f.Format == 0:
|
||||
return Facts{}, fmt.Errorf("not a facts snapshot: it says no format")
|
||||
case f.Format > Format:
|
||||
return Facts{}, fmt.Errorf("a facts snapshot of format %d, and this reads format %d: a newer controller "+
|
||||
"took it, and what it says beyond %d would be judged without", f.Format, Format, Format)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Longest is the length of the longest machine name in the snapshot — what a consumer's identity is
|
||||
// judged on (novox/hq ADR 0225).
|
||||
func (f Facts) Longest() int {
|
||||
longest := 0
|
||||
for _, m := range f.Machines {
|
||||
if m.Length > longest {
|
||||
longest = m.Length
|
||||
}
|
||||
}
|
||||
return longest
|
||||
}
|
||||
|
||||
// Machine is the machine of that pseudonym.
|
||||
func (f Facts) Machine(name string) (Machine, bool) {
|
||||
for _, m := range f.Machines {
|
||||
if m.Name == name {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return Machine{}, false
|
||||
}
|
||||
|
||||
// Described is how a check names a machine: its roles, then its pseudonym.
|
||||
func (m Machine) Described() string {
|
||||
if len(m.Roles) == 0 {
|
||||
return "a machine (" + m.Name + ")"
|
||||
}
|
||||
out := m.Roles[0]
|
||||
for _, r := range m.Roles[1:] {
|
||||
out += ", " + r
|
||||
}
|
||||
return out + " (" + m.Name + ")"
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package facts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A pseudonym keeps what a limit meets — the length, and letters where letters were — and nothing else.
|
||||
func TestAPseudonymKeepsTheLengthAndShapeAndIsStable(t *testing.T) {
|
||||
for _, name := range []string{"ace", "g14", "novox", "shanks", "home-server", "a"} {
|
||||
p := Pseudonym("machine", name)
|
||||
if len(p) != len(name) {
|
||||
t.Errorf("%s became %s: %d characters for %d", name, p, len(p), len(name))
|
||||
}
|
||||
if p == name {
|
||||
t.Errorf("%s was kept as itself", name)
|
||||
}
|
||||
if p != Pseudonym("machine", name) {
|
||||
t.Errorf("%s is not stable", name)
|
||||
}
|
||||
for i := range name {
|
||||
isLetter := func(c byte) bool { return c >= 'a' && c <= 'z' }
|
||||
isDigit := func(c byte) bool { return c >= '0' && c <= '9' }
|
||||
if isLetter(name[i]) != isLetter(p[i]) || isDigit(name[i]) != isDigit(p[i]) {
|
||||
t.Errorf("%s became %s: the shape moved at %d", name, p, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if Pseudonym("machine", "ace") == Pseudonym("site", "ace") {
|
||||
t.Error("a site and a machine of one name share a pseudonym, so a snapshot says they are one thing")
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing of the installation survives the scrubber: names, domains, accounts, addresses, mail, secrets.
|
||||
func TestTheScrubberLeavesNothingOfTheInstallation(t *testing.T) {
|
||||
s := NewScrubber()
|
||||
machine := s.Machine("homeserver")
|
||||
s.Account("jochens")
|
||||
domain := s.Domain("zurag.be")
|
||||
if len(domain) != len("zurag.be") || !strings.HasSuffix(domain, ".be") || domain == "zurag.be" {
|
||||
t.Errorf("the domain became %q", domain)
|
||||
}
|
||||
in := map[string]any{
|
||||
"hub": "homeserver",
|
||||
"listen": "10.42.0.7:51820",
|
||||
"upstream": []any{"192.168.1.135", "fd00::1"},
|
||||
"site": "https://grafana.zurag.be/login",
|
||||
"admin": "jschoubben@gmail.com",
|
||||
"home": "/home/jochens/.ssh",
|
||||
"api_key": "sk-live-abcdef",
|
||||
"nested": map[string]any{"password": "hunter2", "port": float64(5432)},
|
||||
"opaque": "a8F3kQ9zL2mX7vB4nC6dE1rT5yU0iO8pA3sD",
|
||||
"dsn": "postgres://app:s3cr3tpass@db.internal:5432/app",
|
||||
"pem": "-----BEGIN PRIVATE KEY-----\nMIIB",
|
||||
"plain": "a-long-plain-module-directory-name",
|
||||
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"version": "2.11.17",
|
||||
"homeserver": true,
|
||||
}
|
||||
out := s.Values(in)
|
||||
flat := flatten(out)
|
||||
for _, leaked := range []string{"homeserver\"", "10.42.0.7", "192.168.1.135", "fd00::1", "zurag", "jschoubben",
|
||||
"gmail", "jochens", "sk-live", "hunter2", "a8F3kQ9zL2mX7vB4nC6dE1rT5yU0iO8pA3sD", "s3cr3tpass", "MIIB"} {
|
||||
if strings.Contains(flat, leaked) {
|
||||
t.Errorf("%q survived the scrubber:\n%s", leaked, flat)
|
||||
}
|
||||
}
|
||||
if out["hub"] != machine {
|
||||
t.Errorf("a machine named in a setting became %v, not its pseudonym %s", out["hub"], machine)
|
||||
}
|
||||
for _, kept := range []string{"a-long-plain-module-directory-name", "2.11.17", "sha256:44136fa3", "5432"} {
|
||||
if !strings.Contains(flat, kept) {
|
||||
t.Errorf("%q was scrubbed, and it is not the installation's:\n%s", kept, flat)
|
||||
}
|
||||
}
|
||||
// Every address left is a documentation address.
|
||||
for _, a := range regexp.MustCompile(`[0-9a-f:.]{7,}`).FindAllString(flat, -1) {
|
||||
ip := net.ParseIP(strings.Trim(a, ".:"))
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
doc := false
|
||||
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "2001:db8::/32"} {
|
||||
_, n, _ := net.ParseCIDR(cidr)
|
||||
doc = doc || n.Contains(ip)
|
||||
}
|
||||
if !doc {
|
||||
t.Errorf("%s is not a documentation address", a)
|
||||
}
|
||||
}
|
||||
// The same address is always the same stand-in.
|
||||
if s.Text("10.42.0.7") != s.Text("at 10.42.0.7")[3:] {
|
||||
t.Error("one address became two stand-ins")
|
||||
}
|
||||
}
|
||||
|
||||
func flatten(v any) string {
|
||||
var b strings.Builder
|
||||
var walk func(any)
|
||||
walk = func(v any) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range t {
|
||||
b.WriteString(k + "\"=")
|
||||
walk(e)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
case []any:
|
||||
for _, e := range t {
|
||||
walk(e)
|
||||
b.WriteString(",")
|
||||
}
|
||||
case string:
|
||||
b.WriteString(t + "\"")
|
||||
default:
|
||||
b.WriteString(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
walk(v)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Two snapshots of one mesh, taken apart, are one content.
|
||||
func TestASnapshotOfAnUnchangedMeshIsTheSameContentAnotherDay(t *testing.T) {
|
||||
f := Facts{Format: Format, Taken: time.Now(), Machines: []Machine{{Name: "b"}, {Name: "a"}}}
|
||||
g := f
|
||||
g.Taken = f.Taken.Add(24 * time.Hour)
|
||||
g.Machines = []Machine{{Name: "a"}, {Name: "b"}}
|
||||
a, err := f.Content()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := g.Content()
|
||||
if a != b {
|
||||
t.Error("the same mesh a day later reads as a change")
|
||||
}
|
||||
g.Machines = append(g.Machines, Machine{Name: "c"})
|
||||
if c, _ := g.Content(); c == a {
|
||||
t.Error("a machine added reads as no change")
|
||||
}
|
||||
}
|
||||
|
||||
// A reader refuses a snapshot it cannot read whole.
|
||||
func TestANewerSnapshotIsRefusedNotHalfRead(t *testing.T) {
|
||||
if _, err := Decode([]byte(`{"facts": 99}`)); err == nil || !strings.Contains(err.Error(), "newer controller") {
|
||||
t.Errorf("a newer format read as %v", err)
|
||||
}
|
||||
if _, err := Decode([]byte(`{"machines": []}`)); err == nil {
|
||||
t.Error("a document with no format read as a snapshot")
|
||||
}
|
||||
f, err := Decode([]byte(`{"facts": 1, "machines": [{"name": "abc", "length": 3}, {"name": "defgh", "length": 5}]}`))
|
||||
if err != nil || f.Longest() != 5 {
|
||||
t.Errorf("read %+v, %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A path that reads as a key is withheld, and keeps a path's shape: an access or a place must be an
|
||||
// absolute path, and a bare word left a machine the mesh composes refused where a check raised it.
|
||||
func TestAWithheldPathStaysAPath(t *testing.T) {
|
||||
s := NewScrubber()
|
||||
if got := s.Text("/storage/media/Formula1-Season-2026"); got != "/"+Withheld {
|
||||
t.Errorf("a path reading as a key became %q", got)
|
||||
}
|
||||
if got := s.Text("/storage/media/movies"); got != "/storage/media/movies" {
|
||||
t.Errorf("a plain path became %q", got)
|
||||
}
|
||||
if got := s.Text("Hunter2Hunter2Hunter2Hunter2xx"); got != Withheld {
|
||||
t.Errorf("a key became %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Issue 288**: a repository was kept as the URL it was cloned from, the forge's address with it.
|
||||
func TestARepositoryIsNamedWithoutTheForge(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"http://forge.internal:3000/owner/repo.git": "owner/repo",
|
||||
"ssh://git@forge.internal:222/owner/repo": "owner/repo",
|
||||
"git@forge.internal:owner/repo.git": "owner/repo",
|
||||
"owner/repo": "owner/repo",
|
||||
"": "",
|
||||
} {
|
||||
if got := RepositoryName(in); got != want {
|
||||
t.Errorf("%q is named %q, not %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package facts
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A snapshot the controller wrote before grants and bindings existed is read as one that has none. The body
|
||||
// is the format 1 document facts_test.go's TestANewerSnapshotIsRefusedNotHalfRead reads (mesh-controller
|
||||
// 6c2a8d62, internal/facts/facts_test.go), with the fields of a machine that format 1 wrote.
|
||||
func TestASnapshotWithoutGrantsOrBindingsReadsAsHavingNone(t *testing.T) {
|
||||
f, err := Decode([]byte(`{"facts": 1, "machines": [{"name": "abc", "length": 3}, {"name": "defgh", "length": 5,
|
||||
"pins": [{"provision": "store", "machine": "xyz", "module": "postgres"}]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range f.Machines {
|
||||
if len(m.Grants) != 0 || len(m.Bindings) != 0 {
|
||||
t.Errorf("%s reads grants %v and bindings %v from a snapshot that carries none", m.Name, m.Grants, m.Bindings)
|
||||
}
|
||||
}
|
||||
body, err := f.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), "grants") || strings.Contains(string(body), "bindings") {
|
||||
t.Errorf("a machine with none wrote them anyway:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A snapshot that carries them reads back what it carried, and writes the same bytes again.
|
||||
func TestGrantsAndBindingsRoundTripByteForByte(t *testing.T) {
|
||||
f := Facts{Format: Format, Taken: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), Machines: []Machine{{
|
||||
Name: "abcdef", Length: 6,
|
||||
Grants: []Grant{
|
||||
{Provision: "store", ConsumerNode: "ghijkl", ConsumerModule: "mesh-issues", Local: "database"},
|
||||
{Provision: "store", ConsumerNode: "abcdef", ConsumerModule: "records"},
|
||||
},
|
||||
Bindings: []Binding{
|
||||
{ConsumerModule: "records", Provision: "store", ProviderNode: "abcdef", ProviderModule: "postgres"},
|
||||
},
|
||||
}}}
|
||||
first, err := f.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"grants"`, `"bindings"`, `"consumer-node": "ghijkl"`, `"consumer-module": "mesh-issues"`,
|
||||
`"provider-node": "abcdef"`, `"provider-module": "postgres"`, `"local": "database"`} {
|
||||
if !strings.Contains(string(first), want) {
|
||||
t.Errorf("the snapshot does not say %s:\n%s", want, first)
|
||||
}
|
||||
}
|
||||
back, err := Decode(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := back.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
t.Errorf("the snapshot did not round-trip:\nfirst %s\nsecond %s", first, second)
|
||||
}
|
||||
m, _ := back.Machine("abcdef")
|
||||
// Sorted, so two snapshots of one mesh are the same bytes: the grant for the lower node name comes first.
|
||||
if len(m.Grants) != 2 || m.Grants[0].ConsumerNode != "abcdef" || len(m.Bindings) != 1 {
|
||||
t.Errorf("read back grants %+v and bindings %+v", m.Grants, m.Bindings)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine name in a grant or a binding is scrubbed as a pin's is: to the machine's pseudonym, never kept.
|
||||
func TestTheScrubberScrubsTheMachineNamesOfGrantsAndBindings(t *testing.T) {
|
||||
s := NewScrubber()
|
||||
pin := Pin{Provision: "store", Machine: s.Machine("homeserver"), Module: "postgres"}
|
||||
grant := s.Grant("store", "laptop2", "mesh-issues", "database")
|
||||
binding := s.Binding("records", "store", "homeserver", "postgres")
|
||||
if grant.ConsumerNode != s.Machine("laptop2") || grant.ConsumerNode == "laptop2" {
|
||||
t.Errorf("the grant's consumer node is %q", grant.ConsumerNode)
|
||||
}
|
||||
if binding.ProviderNode != pin.Machine || binding.ProviderNode == "homeserver" {
|
||||
t.Errorf("the binding's provider node is %q, not the pin's pseudonym %q", binding.ProviderNode, pin.Machine)
|
||||
}
|
||||
if grant.Provision != "store" || grant.ConsumerModule != "mesh-issues" || grant.Local != "database" ||
|
||||
binding.ConsumerModule != "records" || binding.Provision != "store" || binding.ProviderModule != "postgres" {
|
||||
t.Errorf("the scrubber changed what is not a machine's name: %+v %+v", grant, binding)
|
||||
}
|
||||
if len(grant.ConsumerNode) != len("laptop2") {
|
||||
t.Errorf("the pseudonym %q does not keep the length of the name", grant.ConsumerNode)
|
||||
}
|
||||
}
|
||||
|
||||
// A grant says that a credential was made and for whom, never the credential: no field of either type is
|
||||
// a sealed value, a secret or a hash.
|
||||
func TestNoFieldOfAGrantOrABindingHoldsASecret(t *testing.T) {
|
||||
for _, typ := range []reflect.Type{reflect.TypeOf(Grant{}), reflect.TypeOf(Binding{})} {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
name := typ.Field(i).Name
|
||||
for _, banned := range []string{"Sealed", "Secret", "Hash", "Password"} {
|
||||
if strings.Contains(strings.ToLower(name), strings.ToLower(banned)) {
|
||||
t.Errorf("%s.%s may hold a secret", typ.Name(), name)
|
||||
}
|
||||
}
|
||||
if typ.Field(i).Type.Kind() != reflect.String {
|
||||
t.Errorf("%s.%s is a %s, not a name", typ.Name(), name, typ.Field(i).Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The format number says from which number a snapshot carries them: a reader of 1 refuses 2 rather than
|
||||
// judge without them, and a reader of 2 reads 1.
|
||||
func TestTheFormatSaysFromWhichNumberGrantsAndBindingsAreWritten(t *testing.T) {
|
||||
if GrantsFrom != 2 {
|
||||
t.Errorf("grants and bindings are written from format %d, not 2", GrantsFrom)
|
||||
}
|
||||
if Format < GrantsFrom {
|
||||
t.Errorf("the format is %d, below %d, where grants and bindings begin", Format, GrantsFrom)
|
||||
}
|
||||
if _, err := Decode([]byte(`{"facts": 2, "machines": []}`)); err != nil {
|
||||
t.Errorf("format 2 is not read: %v", err)
|
||||
}
|
||||
if _, err := Decode([]byte(`{"facts": 1, "machines": []}`)); err != nil {
|
||||
t.Errorf("format 1 is not read: %v", err)
|
||||
}
|
||||
if _, err := Decode([]byte(`{"facts": 3, "machines": []}`)); err == nil {
|
||||
t.Error("format 3 is read by a reader of format 2")
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
package facts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Withheld is what a value that reads as a secret becomes. A check composing with it gets a stand-in of
|
||||
// the right kind (a string), never the value.
|
||||
const Withheld = "withheld"
|
||||
|
||||
// Pseudonym is the stable stand-in for a name: the same length, letters for letters and digits for
|
||||
// digits, anything else kept where it was. Stable, so two snapshots of one mesh name a machine alike and
|
||||
// a check's verdict can be compared across them; derived from the name alone, so it needs no key to be
|
||||
// kept anywhere.
|
||||
//
|
||||
// It hides nothing from somebody who can guess the names: that is not its purpose. Its purpose is that a
|
||||
// snapshot — and every fixture, replay or pull-request comment made from one — carries no name of the
|
||||
// installation it came from, while every length a limit meets stays the length it was.
|
||||
func Pseudonym(kind, name string) string {
|
||||
sum := sha256.Sum256([]byte("novox-mesh-facts\x00" + kind + "\x00" + name))
|
||||
out := []byte(name)
|
||||
for i, c := range out {
|
||||
b := sum[i%len(sum)] ^ byte(i/len(sum))
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
|
||||
out[i] = 'a' + b%26
|
||||
case c >= '0' && c <= '9':
|
||||
out[i] = '0' + b%10
|
||||
}
|
||||
}
|
||||
// A name must still read as one: a machine's begins with a letter.
|
||||
if len(out) > 0 && (out[0] < 'a' || out[0] > 'z') && name[0] >= 'a' && name[0] <= 'z' {
|
||||
out[0] = 'a' + sum[0]%26
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Scrubber replaces what a snapshot must not carry, wherever it appears in text.
|
||||
type Scrubber struct {
|
||||
// replace is every real word and what it becomes, longest first, so a domain is replaced before a
|
||||
// label inside it.
|
||||
replace [][2]string
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
// NewScrubber knows the installation's names: machines, sites, accounts, public domains.
|
||||
func NewScrubber() *Scrubber { return &Scrubber{seen: map[string]bool{}} }
|
||||
|
||||
// Machine registers a machine's name and answers its pseudonym.
|
||||
func (s *Scrubber) Machine(name string) string { return s.word("machine", name) }
|
||||
|
||||
// Grant is a grant with the consumer's machine under its pseudonym, as a pin's machine is: the one place a grant is
|
||||
// built from a real name.
|
||||
func (s *Scrubber) Grant(provision, consumerNode, consumerModule, local string) Grant {
|
||||
return Grant{Provision: provision, ConsumerNode: s.Machine(consumerNode), ConsumerModule: consumerModule, Local: local}
|
||||
}
|
||||
|
||||
// Binding is a binding with the provider's machine under its pseudonym.
|
||||
func (s *Scrubber) Binding(consumerModule, provision, providerNode, providerModule string) Binding {
|
||||
return Binding{ConsumerModule: consumerModule, Provision: provision, ProviderNode: s.Machine(providerNode), ProviderModule: providerModule}
|
||||
}
|
||||
|
||||
// Site registers a site's name and answers its pseudonym.
|
||||
func (s *Scrubber) Site(name string) string { return s.word("site", name) }
|
||||
|
||||
// Account registers an account's name and answers its pseudonym.
|
||||
func (s *Scrubber) Account(name string) string { return s.word("account", name) }
|
||||
|
||||
// Domain registers a public domain and answers its stand-in: each label but the last replaced, so the
|
||||
// shape and every length stay.
|
||||
func (s *Scrubber) Domain(domain string) string {
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
labels := strings.Split(domain, ".")
|
||||
for i := range labels {
|
||||
if i == len(labels)-1 && len(labels) > 1 {
|
||||
break
|
||||
}
|
||||
labels[i] = Pseudonym("domain", labels[i])
|
||||
}
|
||||
out := strings.Join(labels, ".")
|
||||
s.add(domain, out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Scrubber) word(kind, name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
out := Pseudonym(kind, name)
|
||||
s.add(name, out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Scrubber) add(from, to string) {
|
||||
if from == "" || s.seen[from] {
|
||||
return
|
||||
}
|
||||
s.seen[from] = true
|
||||
s.replace = append(s.replace, [2]string{from, to})
|
||||
sort.SliceStable(s.replace, func(i, j int) bool { return len(s.replace[i][0]) > len(s.replace[j][0]) })
|
||||
}
|
||||
|
||||
// wordBoundary is what may stand beside a name for it to be that name and not part of another word.
|
||||
func wordBoundary(c byte) bool {
|
||||
return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_')
|
||||
}
|
||||
|
||||
// Text is s with every known name replaced, every address put in a documentation range, every address
|
||||
// of mail replaced, and every run that reads as a secret withheld.
|
||||
func (s *Scrubber) Text(text string) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
text = secretRuns(text)
|
||||
text = emails.ReplaceAllStringFunc(text, func(mail string) string {
|
||||
if strings.HasPrefix(mail, Withheld+"@") {
|
||||
return mail // a URL's withheld credentials, not an address of mail
|
||||
}
|
||||
return "someone@example.org"
|
||||
})
|
||||
text = addresses(text)
|
||||
for _, r := range s.replace {
|
||||
text = replaceWord(text, r[0], r[1])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// replaceWord replaces from where it stands as a word of its own.
|
||||
func replaceWord(text, from, to string) string {
|
||||
var b strings.Builder
|
||||
for {
|
||||
i := strings.Index(text, from)
|
||||
if i < 0 {
|
||||
b.WriteString(text)
|
||||
return b.String()
|
||||
}
|
||||
end := i + len(from)
|
||||
if (i == 0 || wordBoundary(text[i-1])) && (end == len(text) || wordBoundary(text[end])) {
|
||||
b.WriteString(text[:i])
|
||||
b.WriteString(to)
|
||||
} else {
|
||||
b.WriteString(text[:end])
|
||||
}
|
||||
text = text[end:]
|
||||
}
|
||||
}
|
||||
|
||||
// Values is a settings layer with every key that names a secret withheld, and every string scrubbed.
|
||||
func (s *Scrubber) Values(values map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(values))
|
||||
for k, v := range values {
|
||||
// A key may itself be a name — a map of machines to something.
|
||||
key := s.Text(k)
|
||||
if secretKey.MatchString(k) {
|
||||
out[key] = withheldLike(v)
|
||||
continue
|
||||
}
|
||||
out[key] = s.value(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Scrubber) value(v any) any {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return s.Text(t)
|
||||
case map[string]any:
|
||||
return s.Values(t)
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, e := range t {
|
||||
out[i] = s.value(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// withheldLike is a stand-in of the same kind: a string for a string, a list for a list, so a check
|
||||
// composing a setting still finds the shape it expects.
|
||||
func withheldLike(v any) any {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case bool, float64, int, int64:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, e := range t {
|
||||
out[i] = withheldLike(e)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
out := map[string]any{}
|
||||
for k, e := range t {
|
||||
out[k] = withheldLike(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return Withheld
|
||||
}
|
||||
}
|
||||
|
||||
// secretKey is a setting's key that names a secret.
|
||||
var secretKey = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||
|
||||
// emails are addresses of mail: a person's name, or an installation's domain, in either half.
|
||||
var emails = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
|
||||
// secretRun is a run of characters a key, a token or a hash is made of, long enough to be one.
|
||||
var secretRun = regexp.MustCompile(`[A-Za-z0-9+/=_\-]{24,}`)
|
||||
|
||||
// userinfo is the credentials part of a URL.
|
||||
var userinfo = regexp.MustCompile(`(://)[^/@\s:]+:[^/@\s]+@`)
|
||||
|
||||
// secretRuns withholds a URL's credentials and every run that reads as a key: long, and mixing letters
|
||||
// with digits or symbols. A long plain word (a path, a module's name) is left alone.
|
||||
func secretRuns(text string) string {
|
||||
if strings.Contains(text, "-----BEGIN") {
|
||||
return Withheld
|
||||
}
|
||||
text = userinfo.ReplaceAllString(text, "${1}"+Withheld+"@")
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, at := range secretRun.FindAllStringIndex(text, -1) {
|
||||
run := text[at[0]:at[1]]
|
||||
b.WriteString(text[last:at[0]])
|
||||
last = at[1]
|
||||
// A digest names an artifact, not a secret.
|
||||
if strings.HasSuffix(text[:at[0]], "sha256:") {
|
||||
b.WriteString(run)
|
||||
continue
|
||||
}
|
||||
judged := judgeRun(run)
|
||||
// A path withheld keeps a path's shape — an absolute one stays absolute — so a check composing a
|
||||
// place or an access still finds a path where one must be.
|
||||
if judged == Withheld && strings.HasPrefix(run, "/") {
|
||||
judged = "/" + Withheld
|
||||
}
|
||||
b.WriteString(judged)
|
||||
}
|
||||
b.WriteString(text[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// judgeRun is a run withheld when it reads as a key: long, and mixing letters with digits or symbols.
|
||||
func judgeRun(run string) string {
|
||||
{
|
||||
var lower, upper, digit, symbol bool
|
||||
for _, c := range run {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
lower = true
|
||||
case c >= 'A' && c <= 'Z':
|
||||
upper = true
|
||||
case c >= '0' && c <= '9':
|
||||
digit = true
|
||||
default:
|
||||
symbol = true
|
||||
}
|
||||
}
|
||||
classes := 0
|
||||
for _, b := range []bool{lower, upper, digit, symbol} {
|
||||
if b {
|
||||
classes++
|
||||
}
|
||||
}
|
||||
// A sha256 digest names an artifact, not a secret, and a dashed word is a name.
|
||||
if strings.HasPrefix(run, "sha256") || (!digit && !upper) {
|
||||
return run
|
||||
}
|
||||
if classes >= 3 || (digit && (lower || upper) && len(run) >= 32) {
|
||||
return Withheld
|
||||
}
|
||||
return run
|
||||
}
|
||||
}
|
||||
|
||||
// addresses puts every IP address in text into a documentation range (RFC 5737, RFC 3849): the same
|
||||
// address always becomes the same stand-in, so two settings naming one machine still name one.
|
||||
func addresses(text string) string {
|
||||
var b strings.Builder
|
||||
i := 0
|
||||
for i < len(text) {
|
||||
if !addressChar(text[i]) {
|
||||
b.WriteByte(text[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(text) && addressChar(text[j]) {
|
||||
j++
|
||||
}
|
||||
b.WriteString(standIn(text[i:j]))
|
||||
i = j
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func addressChar(c byte) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c == '.' || c == ':'
|
||||
}
|
||||
|
||||
// standIn is the documentation address for a run that is an address, or the run itself.
|
||||
func standIn(run string) string {
|
||||
trimmed := strings.TrimRight(run, ".:")
|
||||
tail := run[len(trimmed):]
|
||||
ip := net.ParseIP(trimmed)
|
||||
switch {
|
||||
case ip == nil:
|
||||
// A port after an IPv4 address reads as part of the run: try without it.
|
||||
if host, port, ok := strings.Cut(trimmed, ":"); ok && strings.Count(trimmed, ":") == 1 &&
|
||||
net.ParseIP(host) != nil && strings.Contains(host, ".") {
|
||||
return standIn(host) + ":" + port + tail
|
||||
}
|
||||
return run
|
||||
case ip.To4() != nil && strings.Contains(trimmed, "."):
|
||||
sum := sha256.Sum256([]byte("v4\x00" + trimmed))
|
||||
ranges := []string{"192.0.2", "198.51.100", "203.0.113"}
|
||||
return fmt.Sprintf("%s.%d", ranges[sum[0]%3], 1+sum[1]%254) + tail
|
||||
case strings.Count(trimmed, ":") >= 2:
|
||||
sum := sha256.Sum256([]byte("v6\x00" + trimmed))
|
||||
return fmt.Sprintf("2001:db8::%x:%x", uint16(sum[0])<<8|uint16(sum[1]), uint16(sum[2])<<8|uint16(sum[3])) + tail
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
// RepositoryName is a repository as `owner/repository`, without the forge's address it was cloned from:
|
||||
// http://forge.internal:3000/owner/repo.git → owner/repo (novox/hq issue 288). What a check matches a
|
||||
// pull request's repository by is its owner and name, never the forge's address, so nothing is lost.
|
||||
func RepositoryName(repository string) string {
|
||||
r := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repository), "/"), ".git")
|
||||
if r == "" {
|
||||
return ""
|
||||
}
|
||||
if _, rest, found := strings.Cut(r, "://"); found {
|
||||
r = rest
|
||||
if _, path, found := strings.Cut(r, "/"); found {
|
||||
r = path
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
} else if at := strings.Index(r, "@"); at >= 0 {
|
||||
// scp-like: git@forge:owner/repo
|
||||
if _, path, found := strings.Cut(r[at+1:], ":"); found {
|
||||
r = path
|
||||
}
|
||||
}
|
||||
parts := strings.Split(strings.Trim(r, "/"), "/")
|
||||
if len(parts) >= 2 {
|
||||
return parts[len(parts)-2] + "/" + parts[len(parts)-1]
|
||||
}
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
Reference in New Issue
Block a user